---
title: Store API
description: "@korajs/store API reference: storage adapters, collections, CRUD, the query builder, reactive subscriptions, and multi-tab coordination."
---

# Store API Reference

`@korajs/store` provides the local storage layer and the collection API that developers interact with for all data operations. It manages persistence (SQLite WASM, IndexedDB), reactive queries, and operation creation.

You do not typically instantiate a `Store` directly. Instead, `createApp()` creates and configures one for you. The collection methods documented here are accessed through the app instance.

```typescript
import { createApp, defineSchema, t } from 'korajs'

const app = createApp({ schema })

// Collection methods are accessed through app.<collectionName>
await app.todos.insert({ title: 'Hello' })
```

---

## Collection methods

Every collection defined in your schema is accessible as a property on the app instance. Each collection provides the following methods.

### .insert(data)

Inserts a new record into the collection. Returns the full record including generated fields (`id`, auto-fields).

```typescript
insert(data: Partial<CollectionRecord>): Promise<CollectionRecord>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `data` | `Partial<CollectionRecord>` | Field values for the new record. Fields with `.default()` or `.auto()` modifiers can be omitted. |

**Returns:** `Promise<CollectionRecord>` -- The inserted record with all fields populated, including the generated `id` (UUID v7) and any auto/default values.

```typescript
const todo = await app.todos.insert({
  title: 'Ship Kora v1',
  // completed defaults to false (from schema)
  // createdAt set automatically (t.timestamp().auto())
})

console.log(todo)
// {
//   id: '0190a6e0-7b3c-7def-8a12-4b5c6d7e8f90',
//   title: 'Ship Kora v1',
//   completed: false,
//   createdAt: 1712188800000
// }
```

### .update(id, data)

Updates an existing record. Only the specified fields are changed. An operation is created containing only the changed fields and their previous values (enabling 3-way merge).

```typescript
update(id: string, data: Partial<CollectionRecord>): Promise<CollectionRecord>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | `string` | The record ID to update. |
| `data` | `Partial<CollectionRecord>` | Fields to change. Only include fields that are changing. |

**Returns:** `Promise<CollectionRecord>` -- The updated record with all fields.

```typescript
const updated = await app.todos.update('0190a6e0-7b3c-7def-8a12-4b5c6d7e8f90', {
  completed: true,
})
```

### .delete(id)

Deletes a record from the collection. Creates a delete operation that propagates to other devices via sync.

```typescript
delete(id: string): Promise<void>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | `string` | The record ID to delete. |

```typescript
await app.todos.delete('0190a6e0-7b3c-7def-8a12-4b5c6d7e8f90')
```

### .findById(id)

Retrieves a single record by its ID. Returns `null` if not found.

```typescript
findById(id: string): Promise<CollectionRecord | null>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | `string` | The record ID to look up. |

**Returns:** `Promise<CollectionRecord | null>` -- The record, or `null` if it does not exist.

```typescript
const todo = await app.todos.findById('0190a6e0-7b3c-7def-8a12-4b5c6d7e8f90')

if (todo) {
  console.log(todo.title)
}
```

---

## Query builder

The query builder provides a chainable API for constructing queries. Start with `.where()` on a collection and chain additional methods. Terminate with `.exec()` to run the query or `.subscribe()` to reactively watch results.

### .where(filter)

Begins a query with a filter condition. Fields in the filter object are matched with equality by default.

```typescript
where(filter: Partial<CollectionRecord>): QueryBuilder
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `filter` | `Partial<CollectionRecord>` | Key-value pairs to match against. All conditions are AND-ed. |

```typescript
const active = app.todos.where({ completed: false })
const assigned = app.todos.where({ assignee: 'alice', completed: false })
```

### .orderBy(field, direction?)

Sorts results by a field.

```typescript
orderBy(field: string, direction?: 'asc' | 'desc'): QueryBuilder
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `field` | `string` | -- | Field name to sort by. |
| `direction` | `'asc' \| 'desc'` | `'asc'` | Sort direction. |

```typescript
app.todos.where({ completed: false }).orderBy('createdAt', 'desc')
```

### .limit(n)

Limits the number of results returned.

```typescript
limit(n: number): QueryBuilder
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `n` | `number` | Maximum number of records to return. |

```typescript
app.todos.where({ completed: false }).orderBy('createdAt').limit(10)
```

### .offset(n)

Skips the first `n` results. Useful for pagination in combination with `.limit()`.

```typescript
offset(n: number): QueryBuilder
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `n` | `number` | Number of records to skip. |

```typescript
// Page 2 of 10 results per page
app.todos.where({ completed: false }).orderBy('createdAt').limit(10).offset(10)
```

### .include(relation)

Includes related records in the query results by following a relation defined in the schema.

```typescript
include(relation: string): QueryBuilder
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `relation` | `string` | Name of a relation target collection to include. |

```typescript
const todosWithProject = await app.todos
  .where({ completed: false })
  .include('project')
  .exec()

// Each todo now has a `project` property with the related record
console.log(todosWithProject[0].project.name)
```

### .count()

Returns the number of records matching the query instead of the records themselves.

```typescript
count(): Promise<number>
```

```typescript
const activeCount = await app.todos.where({ completed: false }).count()
console.log(activeCount) // 42
```

### .exec()

Executes the query and returns the matching records as an array.

```typescript
exec(): Promise<CollectionRecord[]>
```

```typescript
const todos = await app.todos
  .where({ completed: false })
  .orderBy('createdAt', 'desc')
  .limit(10)
  .exec()
```

### .subscribe(callback)

Subscribes to live query results. The callback is called immediately with the current results, and again whenever the result set changes due to local mutations or incoming sync operations.

```typescript
subscribe(callback: (results: CollectionRecord[]) => void): () => void
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `callback` | `(results: CollectionRecord[]) => void` | Function called with the current result set on every change. |

**Returns:** `() => void` -- An unsubscribe function. Call it to stop receiving updates.

```typescript
const unsubscribe = app.todos
  .where({ completed: false })
  .orderBy('createdAt')
  .subscribe((todos) => {
    console.log('Active todos:', todos.length)
  })

// Later: stop watching
unsubscribe()
```

::: warning
Always call the unsubscribe function when you no longer need updates (e.g., when a component unmounts). Failing to unsubscribe causes memory leaks. If you are using React, prefer the `useQuery` hook which handles unsubscription automatically.
:::

---

## Query builder chaining

Methods can be chained in any order before the terminal `.exec()`, `.count()`, or `.subscribe()`. All of the following are equivalent:

```typescript
// Order 1
await app.todos.where({ completed: false }).orderBy('createdAt').limit(5).exec()

// Order 2
await app.todos.where({ completed: false }).limit(5).orderBy('createdAt').exec()
```

A full chaining example:

```typescript
const recentActive = await app.todos
  .where({ completed: false })
  .orderBy('createdAt', 'desc')
  .limit(20)
  .offset(0)
  .include('project')
  .exec()
```

---

## Transactions

Transactions execute multiple mutations atomically. Either all operations succeed, or none do. Use transactions when you need to update multiple records or collections as a single unit.

### app.transaction(fn)

Executes a function within a transaction context. The transaction is committed when the function completes, or rolled back if it throws.

```typescript
transaction(fn: (tx: TransactionProxy) => Promise<void>): Promise<Operation[]>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `fn` | `(tx: TransactionProxy) => Promise<void>` | Function that performs mutations using the transaction proxy. |

**Returns:** `Promise<Operation[]>` -- The operations created by the transaction.

```typescript
const ops = await app.transaction(async (tx) => {
  const order = await tx.orders.insert({ total: 99.99 })
  await tx.lineItems.insert({ orderId: order.id, product: 'Widget', qty: 2 })
  await tx.lineItems.insert({ orderId: order.id, product: 'Gadget', qty: 1 })
})
// All three inserts succeed or fail together
```

The transaction proxy (`tx`) provides the same collection accessors as the app (`tx.orders`, `tx.todos`, etc.), but mutations are buffered and only applied when the function completes successfully.

### app.mutation(name, fn)

A named transaction, identical to `app.transaction()` but with a name that appears in DevTools for easier debugging.

```typescript
mutation(name: string, fn: (tx: TransactionProxy) => Promise<void>): Promise<Operation[]>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `string` | A descriptive name for this mutation (visible in DevTools). |
| `fn` | `(tx: TransactionProxy) => Promise<void>` | Function that performs mutations using the transaction proxy. |

```typescript
await app.mutation('create-order', async (tx) => {
  const order = await tx.orders.insert({ total: 150 })
  await tx.lineItems.insert({ orderId: order.id, product: 'Widget', qty: 3 })
})
```

### TransactionProxy

The transaction proxy exposes collection accessors with the same API as the app-level accessors:

| Method | Description |
|--------|-------------|
| `tx.<collection>.insert(data)` | Insert a record within the transaction. |
| `tx.<collection>.update(id, data)` | Update a record within the transaction. |
| `tx.<collection>.delete(id)` | Delete a record within the transaction. |
| `tx.<collection>.findById(id)` | Read a record (sees uncommitted writes from this transaction). |

::: tip
Queries (`.where()`, `.exec()`) are not available inside transactions. Use `findById()` to look up records you need during the transaction.
:::

---

## Sequences

Sequences generate ordered, formatted identifiers (invoice numbers, order codes, receipt IDs). They are offline-safe: each device maintains its own counter that increments monotonically.

### app.sequences.next(name, config?)

Generates the next value in a named sequence.

```typescript
next(name: string, config?: SequenceConfig): Promise<string>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `string` | Sequence name. Different names maintain independent counters. |
| `config` | `SequenceConfig` | Optional. Format and scope options. |

#### SequenceConfig

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `format` | `string` | `'{name}-{seq:4}'` | Format template. See format tokens below. |
| `scope` | `string` | `undefined` | Scope key. Different scopes maintain independent counters for the same sequence name. |

#### Format tokens

| Token | Description | Example output |
|-------|-------------|----------------|
| `{seq}` | Counter without padding | `1`, `42`, `100` |
| `{seq:N}` | Counter zero-padded to N digits | `{seq:4}` → `0001` |
| `{date}` | Current date as `YYYYMMDD` | `20260508` |
| `{node4}` | First 4 chars of node ID | `a1b2` |
| `{node8}` | First 8 chars of node ID | `a1b2c3d4` |

```typescript
// Default format: name + zero-padded counter
await app.sequences.next('order')        // 'order-0001'
await app.sequences.next('order')        // 'order-0002'

// Custom format
await app.sequences.next('receipt', {
  format: 'REC-{seq:6}',
})                                        // 'REC-000001'

// Scoped sequences (independent counters per scope)
await app.sequences.next('receipt', { scope: 'store-A' })  // 'receipt-0001'
await app.sequences.next('receipt', { scope: 'store-B' })  // 'receipt-0001'
await app.sequences.next('receipt', { scope: 'store-A' })  // 'receipt-0002'
```

### app.sequences.current(name, config?)

Returns the current counter value without incrementing it. Returns `0` for unused sequences.

```typescript
current(name: string, config?: { scope?: string }): Promise<number>
```

```typescript
const count = await app.sequences.current('order')  // 0 (never used)
await app.sequences.next('order')
await app.sequences.next('order')
const count2 = await app.sequences.current('order') // 2
```

### app.sequences.reset(name, config?)

Resets a sequence counter. The next call to `.next()` starts from 1 (or from the specified value).

```typescript
reset(name: string, config?: { scope?: string; to?: number }): Promise<void>
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `config.scope` | `string` | `undefined` | Only reset the counter for this scope. |
| `config.to` | `number` | `0` | Reset the counter to this value. |

```typescript
await app.sequences.reset('order')          // Next .next() returns 'order-0001'
await app.sequences.reset('order', { to: 100 })  // Next .next() returns 'order-0101'
```

---

## State Machine Validation

The store validates state machine transitions during local mutations, preventing invalid transitions before operations are created. State machines are defined in the schema with a set of allowed transitions between states.

### validateStateTransition(collectionName, recordId, stateMachine, currentState, newState)

Validates whether a state transition is allowed by the state machine definition. Called during update operations to enforce transition rules.

```typescript
function validateStateTransition(
  collectionName: string,
  recordId: string,
  stateMachine: StateMachineDefinition,
  currentState: string | null,
  newState: string,
): { valid: boolean; allowedStates: string[] }
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `collectionName` | `string` | Name of the collection (for error messages). |
| `recordId` | `string` | The record being mutated (for error messages). |
| `stateMachine` | `StateMachineDefinition` | The state machine definition with transitions and `onInvalidTransition` policy. |
| `currentState` | `string \| null` | The current state field value. `null` for inserts. |
| `newState` | `string` | The new state value being set. |

**Returns:** `{ valid: boolean; allowedStates: string[] }` -- Whether the transition is valid, plus the list of allowed target states from the current state.

**Behavior:**

| Scenario | Result |
|----------|--------|
| Insert (`currentState` is `null`) | Always valid. Schema validation ensures the value is a valid enum. |
| Same-state transition | Always valid (idempotent). |
| Valid transition | Returns `{ valid: true }` with allowed states. |
| Invalid transition, mode `'reject'` | Throws `InvalidStateTransitionError`. |
| Invalid transition, mode `'last-valid-state'` | Returns `{ valid: false }` so the caller can suppress the field update. |

```typescript
import { validateStateTransition } from '@korajs/store'

const result = validateStateTransition(
  'orders',
  'order-123',
  {
    field: 'status',
    onInvalidTransition: 'reject',
    transitions: {
      pending: ['shipped', 'cancelled'],
      shipped: ['delivered'],
      cancelled: [],
      delivered: [],
    },
  },
  'pending',
  'shipped',
)
// result.valid === true
// result.allowedStates === ['shipped', 'cancelled']
```

### validateUpdateStateMachine(collectionName, recordId, collectionDef, currentRecord, updateData)

Higher-level validation that checks whether an update data object contains a change to the state machine field, and if so, validates the transition. This is the function called internally by the store during `.update()` operations.

```typescript
function validateUpdateStateMachine(
  collectionName: string,
  recordId: string,
  collectionDef: CollectionDefinition,
  currentRecord: Record<string, unknown>,
  updateData: Record<string, unknown>,
): Record<string, unknown>
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `collectionName` | `string` | Name of the collection. |
| `recordId` | `string` | The record being updated. |
| `collectionDef` | `CollectionDefinition` | The collection definition from the schema. |
| `currentRecord` | `Record<string, unknown>` | The current record data (must include the state field). |
| `updateData` | `Record<string, unknown>` | The partial update data. |

**Returns:** `Record<string, unknown>` -- The update data, potentially with the state field removed if the transition is invalid and the mode is `'last-valid-state'`.

**Behavior:**

- If the collection has no state machine, returns `updateData` unchanged.
- If the state field is not in the update data, returns `updateData` unchanged.
- If the transition is valid, returns `updateData` unchanged.
- If the transition is invalid and mode is `'reject'`, throws `InvalidStateTransitionError`.
- If the transition is invalid and mode is `'last-valid-state'`, returns a copy of `updateData` with the state field removed (silently suppresses the invalid change).

```typescript
import { validateUpdateStateMachine } from '@korajs/store'

const filteredData = validateUpdateStateMachine(
  'orders',
  'order-123',
  schema.collections.orders,
  { id: 'order-123', status: 'delivered', total: 99 },
  { status: 'pending', total: 150 },  // 'delivered' -> 'pending' is invalid
)
// With 'last-valid-state' mode: filteredData === { total: 150 }
// (status change silently removed; total update preserved)
```

### InvalidStateTransitionError

Error thrown when a local mutation attempts an invalid state transition (when the state machine's `onInvalidTransition` mode is `'reject'`). Extends `KoraError` with full context for debugging.

```typescript
class InvalidStateTransitionError extends KoraError {
  readonly collection: string
  readonly recordId: string
  readonly field: string
  readonly fromState: string
  readonly toState: string
  readonly allowedStates: string[]
}
```

The error message includes the collection, field, current state, attempted state, and allowed transitions:

```
Invalid state transition in collection "orders": cannot transition field "status"
from "delivered" to "pending". Allowed transitions from "delivered": (none -- terminal state)
```

The error code is `'INVALID_STATE_TRANSITION'`.

---

## Subscription Bloom Filter

The `SubscriptionBloomFilter` is an internal optimization used by the `SubscriptionManager` to reduce the cost of checking which subscriptions are affected by a mutation. It is mostly transparent to application developers, but understanding it can help when reasoning about performance characteristics.

### How it works

When the store has many active subscriptions (e.g., 1,000+), every mutation must check whether each subscription's result set might have changed. The bloom filter provides a fast O(k) pre-check:

- If the filter returns `false` for a collection/field combination, the subscription is **definitely not affected** and can be skipped entirely.
- If the filter returns `true`, the subscription **might be affected** and requires the full precise check.

This reduces the per-mutation subscription check cost from O(n) to approximately O(1) for unrelated mutations, where n is the number of active subscriptions.

### SubscriptionBloomFilter

```typescript
class SubscriptionBloomFilter {
  constructor(expectedItems: number, falsePositiveRate?: number)

  /** Add a collection (and optional field) to the filter */
  add(collection: string, field?: string): void

  /** Check if a collection (and optional field) might be in the filter */
  mightContain(collection: string, field?: string): boolean

  /** Reset the filter, clearing all bits */
  clear(): void

  /** Estimate the current false positive rate */
  estimatedFalsePositiveRate(): number
}
```

| Constructor Parameter | Type | Default | Description |
|-----------------------|------|---------|-------------|
| `expectedItems` | `number` | -- | Expected number of subscription keys. |
| `falsePositiveRate` | `number` | `0.01` | Desired false positive rate (0 to 1). |

The filter uses FNV-1a hashing with Kirsch-Mitzenmacker double hashing to derive multiple hash functions from two base hashes. Bit count and hash count are computed optimally based on the expected items and desired false positive rate.

```typescript
const filter = new SubscriptionBloomFilter(100, 0.01)
filter.add('todos')
filter.add('todos', 'completed')

filter.mightContain('todos')              // true (definitely added)
filter.mightContain('projects')           // false (definitely not added)
filter.mightContain('todos', 'completed') // true (definitely added)
filter.mightContain('todos', 'title')     // false (probably not added)
```

::: tip
You do not need to interact with the bloom filter directly. The `SubscriptionManager` creates and manages it automatically. It activates when the number of subscriptions exceeds the threshold where bloom filter pre-checking provides a performance benefit.
:::

---

## StorageAdapter

The `StorageAdapter` interface defines the contract for storage backends. Kora ships with three implementations. You do not typically implement this yourself unless building a custom storage backend.

```typescript
interface StorageAdapter {
  /** Open or create the database. */
  open(schema: SchemaDefinition): Promise<void>

  /** Close the database and release resources. */
  close(): Promise<void>

  /** Execute a write query (INSERT, UPDATE, DELETE) within a transaction. */
  execute(sql: string, params?: unknown[]): Promise<void>

  /** Execute a read query (SELECT). */
  query<T>(sql: string, params?: unknown[]): Promise<T[]>

  /** Execute multiple operations atomically. */
  transaction(fn: (tx: Transaction) => Promise<void>): Promise<void>

  /** Apply a schema migration. */
  migrate(from: number, to: number, migration: MigrationPlan): Promise<void>

  /** Optional post-open storage state for adapters that can degrade at runtime. */
  getStorageOpenState?(): StorageOpenState | null
}

interface Transaction {
  execute(sql: string, params?: unknown[]): Promise<void>
  query<T>(sql: string, params?: unknown[]): Promise<T[]>
}
```

### Built-in adapters

Each adapter is a class that implements `StorageAdapter`. Adapters ship as separate subpath entry points so that a browser bundle never pulls in Node-only code (and vice versa).

| Adapter | Import | Class | Environment | Notes |
|---------|--------|-------|-------------|-------|
| SQLite WASM + OPFS | `@korajs/store/sqlite-wasm` | `SqliteWasmAdapter` | Browser | Primary adapter. Runs SQLite in a Web Worker with OPFS persistence. Best performance. |
| IndexedDB | `@korajs/store/indexeddb` | `IndexedDbAdapter` | Browser | Durable fallback adapter. Selected when OPFS is absent and used by `createApp()` when OPFS SyncAccessHandle cannot be acquired at runtime. |
| Native SQLite | `@korajs/store/better-sqlite3` | `BetterSqlite3Adapter` | Node.js, Electron | Uses `better-sqlite3` for server-side and desktop applications. |

The Tauri desktop adapter (`tauri-sqlite`) is provided by the separate `@korajs/tauri` package, not by `@korajs/store`.

### Selecting an adapter

At the application level, `createApp()` selects and configures an adapter for you. You can override the choice through the `store` option:

```typescript
const app = createApp({
  schema,
  store: {
    adapter: 'sqlite-wasm',  // or 'indexeddb', 'better-sqlite3'
    name: 'my-app-db',       // Database name (OPFS file name / IndexedDB name)
  },
})
```

If no adapter is specified, Kora selects the best available adapter for the current environment:

1. In browsers: `sqlite-wasm` when the OPFS API is present, otherwise the hand-written `indexeddb` adapter.
2. In Node.js: `better-sqlite3`.

Adapter selection starts up front, based on which APIs the runtime exposes. `createApp()` also checks the SQLite WASM adapter after open: if OPFS SyncAccessHandle cannot be acquired and the worker reports a non-persistent open, Kora closes it and opens the durable IndexedDB adapter instead. This emits `store:storage-fallback`. `store:opfs-unavailable` is reserved for the last-resort case where IndexedDB also cannot open and the store is running in memory.

---

## StoreConfig

`StoreConfig` is the low-level configuration consumed by the `Store` constructor (exported from `@korajs/store`). `createApp()` builds one of these from its higher-level `store` option, so most applications never construct it directly.

Unlike the `createApp` `store` option (where `adapter` is an identifier string such as `'sqlite-wasm'`), `StoreConfig.adapter` is a concrete `StorageAdapter` instance and is required.

```typescript
interface StoreConfig {
  /** Schema definition for the store. */
  schema: SchemaDefinition

  /** A concrete storage adapter instance (not an identifier string). */
  adapter: StorageAdapter

  /** Database name used for per-tab node id keys. Defaults to 'kora-db'. */
  dbName?: string

  /**
   * 'shared' (default): one node id per database in _kora_meta.
   * 'per-tab': a unique node id per browser tab, via sessionStorage.
   */
  isolation?: StoreIsolation  // 'shared' | 'per-tab'

  /** Optional node ID. If omitted, one is generated or loaded from the database. */
  nodeId?: string

  /** Optional event emitter. When provided, local mutations and storage diagnostics are emitted here. */
  emitter?: KoraEventEmitter

  /** Routes local mutations through a unified apply pipeline when provided. */
  localMutationHandler?: LocalMutationHandler

  /** Called when a reactive query subscription is registered (for sync query subsets). */
  onQuerySubscribed?: (descriptor: QueryDescriptor) => () => void

  /**
   * Supplies the key used to encrypt encrypted secret fields at write time.
   * Required only when the schema declares encrypted secret fields.
   */
  secretKeyProvider?: SecretKeyProvider
}
```

`StoreIsolation` is exported as `type StoreIsolation = 'shared' | 'per-tab'`.

---

## Blob storage

`blob` fields do not carry their bytes in the operation log; they carry a small content-addressed `BlobRef` (hash, size, optional MIME type and filename). The bytes themselves live in a blob store keyed by the SHA-256 hash of the content, so identical content is stored once (dedup) and never re-synced to a peer that already holds that hash.

### ContentAddressedBlobStore

All blob backends implement this interface. Reads are integrity-checked: the returned bytes are verified to hash to the requested key, and a mismatch throws `BlobIntegrityError` rather than returning corrupt data.

```typescript
interface ContentAddressedBlobStore {
  /** Store bytes and return their content-addressed reference. Storing content that already exists is a no-op that returns the same reference (dedup). */
  put(bytes: Uint8Array, metadata?: BlobRefMetadata): Promise<BlobRef>

  /** Retrieve the bytes for a hash, or null if absent. Throws BlobIntegrityError on a hash mismatch. */
  get(hash: string): Promise<Uint8Array | null>

  /** Whether the store holds content for the given hash. */
  has(hash: string): Promise<boolean>

  /** Remove the content for a hash. Returns whether anything was removed. */
  delete(hash: string): Promise<boolean>

  /** Number of distinct blobs held. */
  size(): Promise<number>

  /** List the hashes of every blob currently held (order unspecified). Used by garbage collection. */
  list(): Promise<string[]>
}
```

`BlobRef` and `BlobRefMetadata` come from `@korajs/core`:

```typescript
interface BlobRef {
  hash: string          // hex-encoded SHA-256 of the bytes (the content address)
  size: number          // size of the bytes
  mimeType?: string
  filename?: string
  manifestHash?: string // present when the blob was stored for chunked transfer
}

interface BlobRefMetadata {
  mimeType?: string
  filename?: string
}
```

### Implementations

| Class | Import | Environment | Persistence |
|-------|--------|-------------|-------------|
| `MemoryBlobStore` | `@korajs/store` | Any | In-memory (non-persistent). Reference backend for tests. |
| `OpfsBlobStore` | `@korajs/store` | Browser | Persistent, backed by the Origin Private File System. |
| `FilesystemBlobStore` | `@korajs/store/blob-fs` | Node.js | Persistent, backed by the filesystem. |

`MemoryBlobStore`, `OpfsBlobStore`, and `BlobIntegrityError` are exported from the package root (`@korajs/store`). `FilesystemBlobStore` is exported from the `@korajs/store/blob-fs` subpath only, which keeps its `node:fs` dependency out of browser bundles.

### FilesystemBlobStore (Node.js)

```typescript
import { FilesystemBlobStore } from '@korajs/store/blob-fs'

const blobs = new FilesystemBlobStore('/var/data/kora-blobs')

const ref = await blobs.put(bytes, { mimeType: 'image/png', filename: 'avatar.png' })
// ref: { hash, size, mimeType?, filename? }

const back = await blobs.get(ref.hash)   // Uint8Array | null
await blobs.has(ref.hash)                // boolean
await blobs.delete(ref.hash)             // boolean
await blobs.size()                       // number
await blobs.list()                       // string[] of hashes
```

```typescript
class FilesystemBlobStore implements ContentAddressedBlobStore {
  constructor(dir: string)
}
```

The constructor takes a single directory path. Blobs are stored at `<dir>/<hash[0:2]>/<hash>`, sharded by hash prefix so no single directory holds millions of entries. Writes are atomic (write to a temp file, then rename), so a crash mid-write cannot leave a half-written blob under a valid hash.

### OpfsBlobStore (browser)

```typescript
import { OpfsBlobStore, createOpfsBlobStore, createOpfsBlobDirectory } from '@korajs/store'

// Backed by real OPFS. rootDirName defaults to 'kora-blobs'.
const blobs = await createOpfsBlobStore()
```

`OpfsBlobStore` is constructed from an `OpfsBlobDirectory`. In a browser, `createOpfsBlobStore(rootDirName = 'kora-blobs')` returns an `OpfsBlobStore` backed by real OPFS. `createOpfsBlobDirectory` and the `OpfsBlobDirectory` type are also exported for supplying a custom directory backend. Blobs are sharded by hash prefix, deduplicated, and integrity-verified on read, and they survive reloads.

### BlobIntegrityError

```typescript
class BlobIntegrityError extends Error {
  readonly expectedHash: string
  readonly actualHash: string
}
```

Thrown by `get()` when the bytes stored under a hash do not actually hash to that value (corruption or tampering).

---

## Storage diagnostics

Storage adapters can emit diagnostic events through the `KoraEventEmitter` passed to them (via `StoreConfig.emitter`, or the `emitter` option of an adapter's options such as `SqliteWasmAdapterOptions`). These surface conditions that would otherwise fail silently. The event types are defined in `@korajs/core`.

### store:storage-fallback

Emitted by `createApp()` when OPFS persistence was requested but could not be acquired, and Kora recovered by opening the durable IndexedDB adapter instead. This event is informational: data still survives reloads.

```typescript
{
  type: 'store:storage-fallback'
  dbName: string
  from: 'opfs' | 'sqlite-wasm'
  to: 'indexeddb'
  reason: 'lock-conflict' | 'timeout' | 'unsupported'
  message: string
}
```

### store:opfs-unavailable

Emitted only when OPFS persistence was requested, IndexedDB fallback could not open, and the store fell back to a NON-PERSISTENT in-memory database. Anything written in the session is lost on reload. The event is emitted instead of failing silently so the data-loss condition is observable.

```typescript
{
  type: 'store:opfs-unavailable'
  dbName: string
  reason: 'lock-conflict' | 'timeout' | 'unsupported'
  message: string
}
```

| `reason` | Meaning |
|----------|---------|
| `'lock-conflict'` | Another runtime on this origin already holds the OPFS pool for this database. |
| `'timeout'` | The VFS install did not complete in time (common in headless CI). |
| `'unsupported'` | The runtime has no usable OPFS. |

### Other storage events

- `store:db-name-collision`: emitted by the SQLite WASM adapter when another runtime on this origin was already using this database name, so this runtime attached to it as a follower and now shares that database.
- `store:quota-exceeded`: emitted by the IndexedDB adapter when a write fails because the storage quota was exceeded.

To receive any of these, pass an emitter when constructing the adapter:

```typescript
import { SqliteWasmAdapter } from '@korajs/store/sqlite-wasm'

const adapter = new SqliteWasmAdapter({
  workerUrl: '/sqlite-wasm-worker.js',
  emitter,  // KoraEventEmitter; storage diagnostics are emitted here
})
```
