The database API
MinnowDatabase, batch writes, and the options that shape a database.
MinnowDatabase is the engine. It takes a block store and exposes everything
else — SQL, batch writes, the catalog, and maintenance.
import { MinnowDatabase } from "@minnowdb/core";
import { IndexedDbBlockStore } from "@minnowdb/core/storage/indexeddb";
const store = await IndexedDbBlockStore.open({ name: "shop" });
const db = new MinnowDatabase(store, {
compression: "gzip",
bufferPoolBytes: 64 * 1024 * 1024,
});When the engine runs in a worker, MinnowDatabaseClient offers the same
query, write, catalog, index, migration, live-query, snapshot, and maintenance calls on the main
thread, including the direct view, secondary-index, and full-text-index helpers.
Catalog
await db.createTable({
name: "orders",
uniqueKey: "order_id",
columns: [
{ name: "order_id", type: "number" },
{ name: "total", type: "number" },
{ name: "note", type: "string", nullable: true },
],
});
await db.listTables();createTable is the direct API form of CREATE TABLE. The schema builder and
SQL can declare defaults too; use whichever form fits the rest of your setup.
Bulk writes
Parsing an INSERT per row is the wrong shape for loading data. The batch APIs take rows
directly:
await db.insertBatch("orders", [
{ order_id: 1, total: 24.5, note: null },
{ order_id: 2, total: 88.0, note: "gift wrap" },
]);Construct the database with { schema } and these calls are typed by the declaration: the
rows above must carry orders' required columns, and a misspelled table or column will not
compile. See typed batch writes.
Or columns, when you already hold them that way — which skips the pivot the engine would otherwise do:
await db.insertBatch("orders", {
columns: {
order_id: [1, 2],
total: [24.5, 88.0],
note: [null, "gift wrap"],
},
});The full set:
| Call | Effect |
|---|---|
insertBatch(table, input) | Append rows. A duplicate key throws. |
upsertBatch(table, input, options?) | Insert, replacing an allowed row with the same key. |
updateBatch(table, { keys, changes }) | Change named columns on rows addressed by key. |
deleteBatch(table, { keys }) | Remove rows by key. |
insert / upsert / update / delete | Single-row convenience wrappers over the same paths. |
Each returns what it did — rowCount, blockCount, storedBytes, and the published version —
which is enough to drive a progress bar over a large load without a second query.
upsertBatch and upsert accept the same target-row guard as SQL's
ON CONFLICT ... DO UPDATE ... WHERE:
const result = await db.upsertBatch("orders", incoming, {
conflictWhere: { column: "_synced", operator: "=", value: true },
});Fresh keys are always inserted. A conflicting key updates only when its existing row satisfies
the predicate. The result distinguishes requestedRowCount, written rowCount, and
skippedRowCount; an entirely skipped batch writes no blocks and returns segmentId: null. The
guard stays on the columnar batch path and is re-evaluated if another tab commits first.
Query results
Every query and cursor page returns aligned column metadata beside its rows:
const result = await db.query("SELECT payload, JSON_OBJECT('id', order_id) AS summary FROM orders");
result.columns; // ["payload", "summary"]
result.columnDomains; // [{ kind: "jsonb" }, { kind: "json" }]
result.rows; // JSON values remain validated JSON textcolumnDomains[index] describes columns[index]. It is null for an ordinary primitive and a
SqlDomain for json, jsonb, uuid, date, time, numeric, arrays, intervals, enums, and
collated text. Domains are inferred for both declared columns and ad-hoc projections, so a generic
consumer can parse JSON without knowing which query produced the alias.
One batch is one commit. To make several land together, wrap them in a write scope.
Buffered writing
For a stream of small writes — telemetry, edits as a user types — a buffered writer combines them into blocks worth committing:
const writer = db.bufferedWriter("events", { maxRows: 5_000, maxAgeMs: 250 });
await writer.add({ event_id: id, kind: "click", at: new Date() });
await writer.flush();flush() joins earlier queued add() calls and any in-flight flush, then commits the accepted
rows still waiting. attachLifecycleFlush requests a flush on hidden visibility and page-hide
events. These events are best effort and cannot make an uncommitted buffer crash-safe. Await a
strict committed write before acknowledging a sale.
Options
| Option | Default | What it does |
|---|---|---|
schema | none | The schema([...]) declaration this database is typed against. Every batch method is then keyed by table name (see typed batch writes), and a bare migrate() applies it. |
compression | "gzip" | Block encoding. "raw" writes faster; gzip usually uses about half the storage and can make a cold read faster because the browser fetches fewer bytes. |
rowsPerBlock | 65536 | Maximum rows in an ordinary write block, up to the format ceiling of 1,048,576. Smaller blocks can help narrow reads but add more work to broad scans. |
targetBlockBytes | 2 MiB | Target uncompressed bytes in each ordinary write block. Every column uses the same row boundaries. One value may exceed the target, up to the format's 64 MiB hard limit. |
bufferPoolBytes | 64 MiB | Space kept for decoded blocks and reusable query work. 0 disables the cache. Cached data is tied to the exact block or table version, so an old entry is never used for new data. |
ftsAutoIndexRows | 4096 | Row count above which MATCH can schedule a background index build for a column that does not have one yet. |
maxCommitRetries | 8 | How many times a plain write restarts after a writer that did not take a turn publishes first. Writers that take turns never need one. |
spillOwnerLeaseMs | 60000 | How long temporary query files stay claimed between renewals while a query is running. |
transactionOwnerLeaseMs | 30000 | Durable active-writer deadline, renewed while a live write scope waits or works. Expired crashed writers are atomically aborted and reclaimed by collection. |
transactionIdleTimeoutMs | 30000 | How long an untouched BEGIN transaction may remain open before Minnow rolls it back. Later statements fail with TransactionExpiredError until ROLLBACK or a new BEGIN. |
autoCompact | true | Whether Minnow combines fragmented tables in the background; see compaction. |
autoCollect | true | Whether Minnow removes replaced blocks, abandoned state, and old versions in the background; independent of autoCompact. See garbage collection. |
autoCollectDebtLimitCommits | 4096 | Maximum commits allowed to accumulate behind collection. At the ceiling, a writer assists collection before starting and refuses atomically with MaintenanceBacklogError if the assisted pass reclaims nothing. |
executionMemoryBudgetBytes | 64 MiB | Default modeled memory ceiling for every statement and internal query path. Eligible work spills; null explicitly opts into unbounded mode. |
compaction | {} | Defaults for every compaction job, including background work. partitionRows defaults to 16,384. See partitioned folds. |
The stock worker accepts the cloneable options in its startup message, including compression,
block sizing, retry and buffer limits, executionMemoryBudgetBytes, transactionOwnerLeaseMs,
transactionIdleTimeoutMs, autoCompact, autoCollect, and autoCollectDebtLimitCommits.
Function-valued seams remain worker-side. A
custom worker entry can construct
MinnowDatabase with the full option set.
Errors
Errors are classes, so they can be caught by kind rather than by matching a message:
import { SqlCompileError, UniqueConstraintError, UnknownTableError } from "@minnowdb/core";
import { WriteConflictError } from "@minnowdb/core/storage/contracts";SqlCompileError carries the offset and length of the offending span, which is what the
devtools editor underlines. WriteConflictError means a writer that did not take a turn — an
older build in another tab, say — committed first; writers that take turns never see it. Plain
writes restart up to maxCommitRetries; an explicit scope fails without replaying its callback.
See writer turns.
UnknownTableError carries tableName, including across the worker boundary, so schema self-heal
can use instanceof instead of matching error text.
Closing
await db.close();
store.close();db.close() rejects new work, cancels open write/snapshot callbacks and an open BEGIN, closes cursors and export iterators, stops live-query polling and background scheduling,
releases the engine's reader lease, and drops resident caches. MinnowDatabase does not take
ownership of the injected store, so close it second. Admitted storage work and lease cleanup are
joined before close returns; a callback resumed afterward cannot publish. A MinnowDatabaseClient performs both steps
when you call await client.close(); pass { terminateWorker: true } when its dedicated worker
has no other job.