Engine

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:

CallEffect
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 / deleteSingle-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 text

columnDomains[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

OptionDefaultWhat it does
schemanoneThe 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.
rowsPerBlock65536Maximum 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.
targetBlockBytes2 MiBTarget 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.
bufferPoolBytes64 MiBSpace 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.
ftsAutoIndexRows4096Row count above which MATCH can schedule a background index build for a column that does not have one yet.
maxCommitRetries8How many times a plain write restarts after a writer that did not take a turn publishes first. Writers that take turns never need one.
spillOwnerLeaseMs60000How long temporary query files stay claimed between renewals while a query is running.
transactionOwnerLeaseMs30000Durable active-writer deadline, renewed while a live write scope waits or works. Expired crashed writers are atomically aborted and reclaimed by collection.
transactionIdleTimeoutMs30000How long an untouched BEGIN transaction may remain open before Minnow rolls it back. Later statements fail with TransactionExpiredError until ROLLBACK or a new BEGIN.
autoCompacttrueWhether Minnow combines fragmented tables in the background; see compaction.
autoCollecttrueWhether Minnow removes replaced blocks, abandoned state, and old versions in the background; independent of autoCompact. See garbage collection.
autoCollectDebtLimitCommits4096Maximum 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.
executionMemoryBudgetBytes64 MiBDefault 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.

On this page