Reference

Extending Minnow

Public APIs for building a typed layer, schema tool, or adapter on top of the engine.

SQL is the foundation. The engine runs statements on its own, and client adapters — including the official @minnowdb/kysely package — use only the public APIs on this page. That keeps the page grounded in a real adapter instead of a set of hooks that have never been used outside the engine.

The building blocks

APIImportWhat it gives you
MinnowSqlExecutor@minnowdb/coreStructural query / queryCursor / execute boundary, direct or worker-backed.
MinnowSqlDriver@minnowdb/coreThe SQL executor plus introspect() for schema-aware adapters.
LiveQuerySource / LiveQueryManager@minnowdb/core/liveAdapter-owned typed execution plus Minnow-owned durable invalidation.
execute(sql, params, options?)@minnowdb/coreOne entry point whose kind tells you what happened.
introspect()@minnowdb/coreThe catalog: stable IDs, keys, constraints, triggers, views.
Statement transactions@minnowdb/coreBEGIN, savepoints, COMMIT, and ROLLBACK for adapter-owned control flow.
Plan construction@minnowdb/core/planBuild the same logical plan the SQL parser builds, and hand it to the engine.
sql-feature-matrix.json@minnowdb/coreA machine-readable list of supported and rejected SQL.
postgres-feature-profile.json@minnowdb/corePostgreSQL-compatible forms, deliberate differences, extensions, and embedded exclusions.

Running statements

Everything the engine can do is reachable through one call. The result's kind tells a caller what happened without a second query:

const result = await database.execute(
  `INSERT INTO orders (order_id, total) VALUES ($1, $2)`,
  [1, 25],
);
// { kind: "insert", table: "orders", rowCount: 1, version: 4 }

Emit PostgreSQL's $1, $2 placeholders rather than building SQL strings. Minnow also accepts ? as adapter shorthand, but $n is the public dialect target. The compiled plan is cached on the statement text and re-bound per execution, so parameters are faster as well as safer — and a layer that inlines literals instead defeats that cache, since every distinct value becomes a distinct key.

Identifiers quote with double quotes, doubling an embedded quote: "order id", "say ""hi""".

execute also takes an optional third argument with the engine controls a query does — { signal?, onStats?, memoize?, executionMemoryBudgetBytes? }. A SELECT honors all of them, so an adapter can cancel a buffered statement or report its cost; every other statement checks signal once before running. A driver written against the two-argument execute is still a valid MinnowSqlExecutor — it simply ignores the controls.

Use queryCursor(sql, { params, batchRows, signal }) when an adapter exposes streaming. It returns query-result pages through the same structural interface on MinnowDatabase and MinnowDatabaseClient; the worker implementation transfers one columnar page per pull.

Adding typed live queries

A query-builder adapter should keep execution in the builder and give Minnow only the statement it needs to track:

import { createLiveQueryManager } from "@minnowdb/core/live";

const manager = createLiveQueryManager(driver);
const watched = manager.watch({
  query: { kind: "sql-query", sql: compiled.sql, params },
  execute: (signal) => builder.execute({ signal }),
});

That returns LiveQuery<AwaitedRow> with a stable external-store snapshot and async iteration. Compile once when wrapping the query, snapshot parameter values, and execute through the source library so its result plugins remain in force. A library may add a composition method around the callable wrapper—Kysely uses $call—but core does not depend on one library's method name.

Use KeyedLiveQuery when the adapter exposes exact keyed patches. Restrict its key to a unique, non-null scalar result column, and prefer an ordered limited query for UI windows. See the complete live-query guide.

Introspecting the catalog

introspect() returns what a schema tool needs to diff a live database against a desired state. It is deliberately richer than listTables(), which answers what a reader needs:

const catalog = await database.introspect();

for (const table of catalog.tables) {
  table.name;
  table.uniqueKeyColumnId; // scalar identity, when present
  table.primaryKeyColumnIds; // ordered scalar or composite primary identity
  table.columns; // { id, name, type, integer?, sqlDomain?, nullable, defaultValue?, enumValues?, isAutoIncrementing }
  table.foreignKeys; // { name, columns, parentTable, parentColumns, onDelete, enforced }
  table.checks; // { name, sql }
  table.triggers; // { id, name, event, timing }; id is immutable until that trigger is dropped
}

for (const declared of catalog.views) {
  declared.name;
  declared.sql; // the query text it stands for
  declared.columns; // the query's inferred output schema
  declared.managed; // true when a migration created it, and may therefore drop it
}

Two things make it plannable rather than merely descriptive:

  • Column IDs are stable across renames. A rename is only expressible as a diff because the column keeps its ID; matching on names alone cannot tell a rename from a drop plus an add.
  • Trigger IDs identify exact objects. A trigger keeps its ID for its lifetime. A later trigger may reuse a dropped name but receives a different ID, so tools can detect drop/recreate races.
  • Derived facts are resolved for you. isAutoIncrementing is reported directly rather than leaving a planner to decode a default spec.

Tables and views are sorted by name, so a diff over two catalogs is stable.

Planning a migration

planMigration diffs a Catalog against a schema declaration. It takes the published catalog and nothing else — no database, no store, no engine — so a tool can plan against a catalog it fetched, cached, or built by hand:

import { planMigration, schema, table, column } from "@minnowdb/core";

const catalog = await database.introspect(); // or any Catalog value you have
const plan = planMigration(catalog, schema([table("notes", {/* ... */})]));

for (const step of plan.steps) {
  step.kind; // "create-table" | "add-column" | "rename-column" | "widen-nullable" |
  // "tighten-nullable" | "set-auto-increment" | "widen-enum" | "alter-default" |
  // "alter-generated" | "alter-foreign-keys" | "drop-column" | "drop-table" |
  // "replace-view" | "drop-view"
}

Planning is a pure function, so it is also how you preview: run it, show the steps, and decide whether to apply. Anything it cannot prove safe throws with a message naming the fix rather than appearing as a step — see the rejected list.

Applying still goes through the engine. database.migrate(schema) plans and applies in one call. Some steps have no SQL spelling — a rename happens through the column's stable ID, which ALTER TABLE cannot express — so there is no equivalent statement list to run yourself. If you need the split, plan with planMigration to decide and inspect, then hand the same schema to migrate() to apply.

Transactions

Statement transactions use explicit BEGIN, COMMIT, and ROLLBACK calls. That fits a layer that needs to decide its own control flow:

await database.execute("BEGIN");
try {
  await database.execute(`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, [10, 1]);
  await database.execute(`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, [10, 2]);
  await database.execute("COMMIT");
} catch (error) {
  await database.execute("ROLLBACK");
  throw error;
}

A layer can wrap these calls in a callback-based helper. For nested work, emit SAVEPOINT name, ROLLBACK TO SAVEPOINT name, and RELEASE SAVEPOINT name; a second BEGIN is still rejected.

Building plans directly

@minnowdb/core/plan exposes the functions the SQL parser uses to assemble and check a query plan. A builder that uses them gets the same validation messages and execution path as parsed SQL.

import { assembleSelectBlock, optimizePlan, type CompiledQuery } from "@minnowdb/core/plan";

This is the lowest-level API here, and the one most likely to change shape as the plan types move into a module of their own. Most layers should emit SQL and let the engine parse it: parsing costs 11–28 µs, which is under 1% of any query that touches real data.

Discovering what the engine accepts

A layer that generates SQL needs a precise support boundary. The feature matrix provides it as data:

import matrix from "@minnowdb/core/sql-feature-matrix.json" with { type: "json" };

const unsupported = matrix.features.filter((entry) => entry.status === "unsupported");
// each carries: id, example, error, and the reason for the boundary

The engine's tests read this same file and the PostgreSQL profile beside it, so a change to the language cannot leave either list quietly out of date.

Generators should account for these narrower supported forms:

BoundaryRule
keyless UPDATE / DELETErows need scalar or composite identity
correlated scalar inner GROUP BYaggregate the correlated row set without inner groups
range-correlated LATERAL grouping/order/limitequality-correlated lateral queries may group and limit
correlated JSON_TABLE documentsonly constant documents with $ and $[*] row paths
enum/sequence evolutionno ALTER TYPE, sequence options, or ALTER SEQUENCE
server/session commandsno schemas, GRANT, or SERIALIZABLE isolation

Each rejected form raises an explicit error. Supported entries with a scoped boundary carry that boundary in their matrix note.

Row types

If your adapter is typed, build its database type from the catalog or from your application's own schema types. The Kysely adapter uses Kysely's ordinary table-to-row DB interface and does not require Minnow-specific wrappers.

On this page