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
| API | Import | What it gives you |
|---|---|---|
MinnowSqlExecutor | @minnowdb/core | Structural query / queryCursor / execute boundary, direct or worker-backed. |
MinnowSqlDriver | @minnowdb/core | The SQL executor plus introspect() for schema-aware adapters. |
LiveQuerySource / LiveQueryManager | @minnowdb/core/live | Adapter-owned typed execution plus Minnow-owned durable invalidation. |
execute(sql, params, options?) | @minnowdb/core | One entry point whose kind tells you what happened. |
introspect() | @minnowdb/core | The catalog: stable IDs, keys, constraints, triggers, views. |
| Statement transactions | @minnowdb/core | BEGIN, savepoints, COMMIT, and ROLLBACK for adapter-owned control flow. |
| Plan construction | @minnowdb/core/plan | Build the same logical plan the SQL parser builds, and hand it to the engine. |
sql-feature-matrix.json | @minnowdb/core | A machine-readable list of supported and rejected SQL. |
postgres-feature-profile.json | @minnowdb/core | PostgreSQL-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.
isAutoIncrementingis 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 boundaryThe 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:
| Boundary | Rule |
|---|---|
keyless UPDATE / DELETE | rows need scalar or composite identity |
correlated scalar inner GROUP BY | aggregate the correlated row set without inner groups |
range-correlated LATERAL grouping/order/limit | equality-correlated lateral queries may group and limit |
correlated JSON_TABLE documents | only constant documents with $ and $[*] row paths |
| enum/sequence evolution | no ALTER TYPE, sequence options, or ALTER SEQUENCE |
| server/session commands | no 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.