Kysely
Run Kysely queries against Minnow in the main thread or a worker.
@minnowdb/kysely lets an existing Kysely application use Minnow as its browser-local database.
It uses Kysely's PostgreSQL compiler, so generated statements use double-quoted identifiers, $1
parameters, RETURNING, and the PostgreSQL forms Minnow supports.
npm install @minnowdb/core @minnowdb/kysely kyselyThe adapter supports Kysely 0.29.5 and later 0.29 releases.
Define once and connect
Pass the same schema you migrate to createKysely. It derives Kysely's complete DB map, so
tables and columns are not retyped. migrate() changes the database; createKysely() only creates
the query interface, so keep the explicit migration call:
import { createKysely } from "@minnowdb/kysely";
import { MinnowDatabase, column, schema, table } from "@minnowdb/core";
import { IndexedDbBlockStore } from "@minnowdb/core/storage/indexeddb";
const orders = table("orders", {
order_id: column.integer().unique().autoIncrement(),
customer: column.string(),
total: column.numeric({ precision: 12, scale: 2 }).default("0"),
status: column.enum(["open", "closed"]).default("open"),
note: column.string().nullable(),
});
const appSchema = schema([orders]);
const database = new MinnowDatabase(await IndexedDbBlockStore.open({ name: "shop" }));
await database.migrate(appSchema);
const db = createKysely({ driver: database, schema: appSchema });
const orderRows = await db
.selectFrom("orders")
.select(["order_id", "total"])
.where("total", ">=", 25)
.orderBy("total", "desc")
.execute();Native numeric and JSON results
Core keeps exact NUMERIC and JSON-family values as strings at its lossless JavaScript boundary.
Applications that prefer native values can ask the Kysely driver to decode them from each
result's columnDomains metadata:
const db = createKysely({
driver: database,
schema: appSchema,
resultDecoding: { numeric: "number", json: "parse" },
});numeric: "number" converts NUMERIC/DECIMAL results to finite JavaScript numbers and changes the
inferred select type to number; use it only when Float64 precision is acceptable. json: "parse" parses JSON/JSONB into the exported MinnowJsonValue type. Decoding applies to buffered
queries, .stream(), and mutation RETURNING rows. With no option, lossless strings remain the
default. UUID, arrays, DATE, TIME, intervals, and enums remain strings in both modes.
The inferred Kysely columns retain Minnow's separate select, insert, and update types:
- defaults and nullable columns are optional on insert;
- stored generated columns are omitted from insert and update inputs;
- enum values remain literal unions;
- exact
NUMERICselects as a losslessstring, while writes and predicates naturally acceptstring | number— inwhere,having, joinon,ON CONFLICT ... WHERE,between,case().when,MERGEconditions, and aggregatefilterWhere. A column read through a derived table (selectFrom(subquery.as("sub"))) and an aggregate compared inhavingcarry only the select type, so compare those with a string such as"25"; - scalar and composite primary-key columns cannot be updated;
- declared views select normally and accept no column values on insert or update.
If an existing factory constructs Kysely, use the exported type directly:
import { Kysely } from "kysely";
import { MinnowDialect, type InferKyselyDatabase } from "@minnowdb/kysely";
type DB = InferKyselyDatabase<typeof appSchema>;
const db = new Kysely<DB>({
dialect: new MinnowDialect({ driver: database, schema: appSchema }),
});Pass schema to the dialect when you construct Kysely yourself. It supplies the exact DB type and
lets the compiler normalize a batch of empty value objects. Literal and SQL-expression defaults
live only in Minnow's catalog, so Kysely, raw SQL, batches, workers, and other tabs all get the
same behavior. An empty Kysely value object compiles to PostgreSQL's normal DEFAULT VALUES
form, and a batch of empty objects uses DEFAULT value slots, so catalog defaults run once per
row without adapter-specific SQL.
The generated SQL stays visible and ordinary. Omitted object properties compile to SQL omission
or DEFAULT; explicit null remains a bound NULL:
const query = db.insertInto("orders").values({ customer: "Ada" });
const compiled = query.compile();
// compiled.sql:
// insert into "orders" ("customer") values ($1)
// compiled.parameters: ["Ada"]The catalog fills order_id, total, and status when the statement executes; Kysely does not
hide generated parameters in the compiled query. INSERT ... SELECT may omit default-bearing
target columns for the same reason: the engine evaluates their stored SQL once per selected row.
Typed correlated subqueries
Kysely carries every visible outer alias into an expression builder's nested selectFrom, so
correlated references remain checked through multiple levels. The result row is inferred from the
outer selection as usual:
const visibleOrders = await db
.selectFrom("orders as candidate")
.select("candidate.order_id")
.where((outer) =>
outer.or([
outer("candidate.status", "=", "open"),
outer.exists(
outer
.selectFrom("orders as sibling")
.select("sibling.order_id")
.whereRef("sibling.customer", "=", "candidate.customer")
.where((middle) =>
middle.exists(
middle
.selectFrom("orders as paid")
.select("paid.order_id")
.whereRef("paid.customer", "=", "sibling.customer")
.where("paid.total", ">", 0),
),
),
),
]),
)
.execute();
// Array<{ order_id: number }>IN, NOT IN, eb.fn.any(subquery), and scalar query builders use the same nested scopes.
Mutation predicates do too, including typed RETURNING rows:
const removed = await db
.deleteFrom("orders")
.where(
"orders.order_id",
"not in",
db.selectFrom("orders").select("order_id").where("status", "=", "open"),
)
.returning("orders.order_id")
.execute();
// Array<{ order_id: number }>The adapter compiles these builders to ordinary PostgreSQL SQL for Minnow's shared planner.
Kysely has no first-class ALL (subquery) builder or correlated selectNoFrom on an
expression builder. Those
two SQL spellings use sql<T> with an explicit result type, while references interpolated from
eb.ref(...) remain scope-checked.
Nested JSON projections
Import Minnow's JSON helpers. They keep Kysely's exact object, nullable-object, and object-array
inference while emitting Minnow's SQL-standard JSON_OBJECT and JSON_ARRAYAGG forms. The
engine also runs the SQL kysely/helpers/postgres emits — json_agg, json_build_object, and
to_json over a derived-table alias — so those helpers work too, but Minnow's are the typed
default:
import { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "@minnowdb/kysely/helpers";
const ordersWithRelated = await db
.selectFrom("orders as order")
.select((eb) => [
"order.order_id",
jsonBuildObject({
id: eb.ref("order.order_id"),
status: eb.ref("order.status"),
}).as("summary"),
jsonArrayFrom(
eb
.selectFrom("orders as related")
.select(["related.order_id as id", "related.total"])
.whereRef("related.customer", "=", "order.customer")
.orderBy("related.total", "desc")
.limit(5),
).as("related"),
jsonObjectFrom(
eb
.selectFrom("orders as latest")
.select(["latest.order_id as id", "latest.status"])
.whereRef("latest.customer", "=", "order.customer")
.orderBy("latest.order_id", "desc")
.limit(1),
).as("latest"),
])
.execute();
// Array<{
// order_id: number;
// summary: { id: number; status: "open" | "closed" };
// related: Array<{ id: number; total: number }>;
// latest: { id: number; status: "open" | "closed" } | null;
// }>jsonArrayFrom returns [] for no rows. jsonObjectFrom returns null for no row and raises the
normal scalar-subquery cardinality error for more than one row; use ordering and a limit when the
relationship is not unique. Ordering, limits, and offsets run independently for each correlated
outer row, including when several JSON helpers are siblings in one select list.
Row-to-object helpers require explicit named selections: select columns directly or alias computed
expressions. They reject selectAll() because the helper must name each JSON key in emitted SQL.
Enable resultDecoding: { json: "parse" } on createKysely or MinnowDialect so runtime values
match the inferred object types; without it, Minnow's lossless JSON strings remain the database
boundary. The same helpers are also exported from the package root.
Reading back into a stored document uses Kysely's JSON references, which compile to PostgreSQL's
-> and ->> operators:
const names = await db
.selectFrom("profiles")
.select((eb) => [
eb.ref("document", "->>").key("name").as("name"),
eb.ref("document", "->>").key("tags").at(0).as("first_tag"),
])
.where((eb) => eb(eb.ref("document", "->>").key("meta").key("score"), "=", "9"))
.execute();.key() selects an object member and .at() an array element, counting from the end for a
negative position. -> returns a JSON value, ->> returns text — with a selected JSON null as
SQL NULL — and ->> values cross this adapter as strings, so a numeric comparison casts or
compares text as above. See the SELECT guide for the operators' semantics.
Typed traversal is driven by the column's TypeScript type, so it needs a declared document
shape. A schema-derived createKysely database gets one from the column itself — pass the
shape to column.json<Shape>() or column.jsonb<Shape>():
interface ProfileDocument {
name: string;
tags: string[];
meta: { score: number };
}
const profiles = table("profiles", {
id: column.integer().unique(),
document: column.jsonb<ProfileDocument>().nullable(),
});The declared shape types .key() and .at() — member names complete, a misspelled key is a
compile error, and leaf types are inferred — while the column itself still selects as JSON text
(JsonShape<ProfileDocument>, a branded string), or as the declared shape itself under
resultDecoding: { json: "parse" }. The shape is a compile-time promise, not a validator:
writes still take JSON text, and no runtime check confirms stored documents match it. Without a
declared shape, a JSON column types as its lossless string boundary (or MinnowJsonValue with
decoding), which offers no member names to traverse — declare the shape, write those reads with
sql<T> and JSON_VALUE, or hand-write a Kysely<DB> interface whose column is the object
type. Two accuracy notes on Kysely's inferred leaf types: a -> result matches its inferred
object type at runtime only with resultDecoding: { json: "parse" } (otherwise it is JSON
text), and a ->> result is always text at runtime even where the declared member is a number
or array — treat it as a string, exactly as the comparison above does.
Inferred function results
Kysely normally uses portable driver unions for numeric aggregates because it cannot know which database will execute a query. Schema-derived Minnow columns carry that missing information, so the adapter infers Minnow's actual JavaScript boundary without output generics:
const totals = await db
.selectFrom("orders")
.select((eb) => [
eb.fn.countAll().as("orders"), // number
eb.fn.sum("order_id").as("id_total"), // number | null
eb.fn.sum("total").as("revenue"), // string | null: total is exact NUMERIC
])
.execute();count() and countAll() infer number. sum() and avg() infer number | null for ordinary
numeric columns and lossless string | null for exact NUMERIC / DECIMAL; min() and max()
also include the empty-input NULL. With native numeric result decoding, exact numeric columns and
their aggregates infer number instead. Use eb.fn.coalesce(aggregate, eb.val(fallback)) when the query
wants a non-null fallback—the result narrows without a generic. The generic fn and fn.agg
entry points also know Minnow's supported fixed-return functions and aggregates, so calls such as
eb.fn("round", ...), eb.fn("date_trunc", ...), and eb.fn.agg("count", ...) infer their
results. round, abs, floor, ceil, and mod over an exact NUMERIC column stay exact
in the engine, so they infer the column's boundary: string, or number with native numeric
result decoding. power and sqrt run in Float64, so over an exact NUMERIC column they are a
type error rather than a number; CAST the column to double precision first, or aggregate
it with fn.sum/fn.avg, which keep the lossless string boundary. coalesce,
nullif, greatest, and least infer from their arguments. Nullable scalar
inputs are carried into the result where the SQL function propagates NULL. eb.cast(value, target)
also needs no output generic for Minnow's supported targets: integer and floating targets infer
number, exact numeric infers the configured string/number boundary, other string-backed logical
domains infer string, boolean infers boolean,
DATE infers canonical YYYY-MM-DD text, and datetime/timestamp targets infer Date.
These conditional overloads leave ordinary Kysely database maps unchanged. When constructing
Kysely manually, use InferKyselyDatabase<typeof appSchema> so the DB map retains Minnow's type
metadata.
An arbitrary function name or raw SQL string is the deliberate boundary. TypeScript does not parse their SQL semantics, so give Kysely the result type there:
import { sql } from "kysely";
const row = await sql<{ orders: number }>`SELECT COUNT(*) AS orders FROM orders`.execute(db);Do not add an output generic to a schema-derived count, numeric aggregate, known Minnow function,
or supported cast—the adapter already has enough information to infer it.
MinnowDatabaseClient works in the same position, so moving the engine to a worker changes only
how database is constructed. Both implement MinnowSqlDriver structurally.
Full-text search
Use search.match and search.rank when a Kysely query needs Minnow's full-text SQL:
import { search } from "@minnowdb/kysely";
const query = "copper";
const products = await db
.selectFrom("products")
.select((eb) => [
"name",
"brand",
"list_price",
search.rank(eb, ["name", "brand"], query).as("rank"),
])
.where((eb) => search.match(eb, ["name", "brand"], query))
.orderBy("rank", "desc")
.limit(10)
.execute();The non-empty column list is checked against the tables and aliases visible at that point in the query. A misspelled or out-of-scope column is a compile error, the rank is inferred as a number, and the search text is a bound parameter rather than SQL text. Any SQL column type is valid; Minnow searches numbers and dates through their canonical text form too.
Search stays inside the SQL engine. Small tables scan immediately, large append-only tables get
automatic persisted indexes, and BM25 uses whole-column term statistics. See
Full-text search for prefix queries, index behavior, and tuning.
Live queries
createKyselyLiveQueries wraps an ordinary selectable query without changing its inferred row
type. Use the callable directly or through Kysely's $call:
import { createKyselyLiveQueries } from "@minnowdb/kysely";
const live = createKyselyLiveQueries({ driver: database, db });
const openOrders = db
.selectFrom("orders")
.select(["order_id", "total"])
.where("status", "=", "open")
.$call(live);
const unsubscribe = openOrders.subscribe(() => {
const snapshot = openOrders.getSnapshot();
if (snapshot.status === "ready") render(snapshot.rows);
});The wrapper compiles the statement once for dependency tracking. With db, a changed result is
delivered by the engine and decoded here through the dialect's result decoding and the instance's
plugins, so mapped row names survive and nothing executes twice; plugins added to a single builder
with withPlugin are not applied to delivered results, so keep them on the instance. Without
db, each change is answered by executing through Kysely again. It also provides typed keyed
changes and ordered, bounded windows. See Live queries for those forms and the
adapter-neutral contract another query library can implement.
Streaming
Kysely's standard .stream(chunkSize) pulls from Minnow's query cursor:
for await (const order of db.selectFrom("orders").select(["order_id", "total"]).stream(1000)) {
consume(order);
}Directly streamable scans honor backpressure through the vector executor and worker boundary. Blocking SQL remains correct through the engine's materialized/spill path. The cursor and export guide explains the boundary.
.stream() on an insert, update, or delete with RETURNING runs the statement buffered and hands
its returned rows out in chunks of chunkSize, because Minnow's cursor reads only SELECT.
chunkSize must be a positive whole number; an invalid size is rejected before the mutation runs.
Cancellation
Kysely's abortable execution options forward to the engine, for buffered queries as well as streams:
const controller = new AbortController();
const pending = db.selectFrom("orders").selectAll().execute({ signal: controller.signal });
controller.abort(new Error("view closed"));
await pending.catch(() => undefined); // rejects with the abort reasonThe engine checks the signal between bounded execution and storage batches, so a cancelled
buffered SELECT stops doing work — it returns no partial result and releases any reader lease
and temporary spill state before the promise rejects. A mutation statement checks the signal
once before it starts running: an already-aborted signal prevents the write entirely, but a
mutation that has begun publishes completely or fails — the abort never leaves half a statement
applied.
Supported Kysely operations
- Reads, inserts, updates, deletes, and their
$nparameters, includingdistinctOn(),updateTable().from(),deleteFrom().using(), and the row-locking modifiers (forUpdate(),forShare(),noWait(),skipLocked(), and friends), which the engine accepts and ignores. - Type-safe
MATCHpredicates andBM25ranking expressions. - Typed live queries, keyed changes, and ordered bounded windows.
.stream(chunkSize)over direct and worker-backed query cursors, and over the bufferedRETURNINGrows of a mutation.AbortSignalcancellation for buffered.execute({ signal })and for.stream().RETURNINGrows and Kysely's affected-row counts.db.transaction().execute(...)through Minnow'sBEGIN,COMMIT, andROLLBACK.- Controlled transactions:
db.startTransaction()withsavepoint(name),rollbackToSavepoint(name), andreleaseSavepoint(name)mapped onto Minnow'sSAVEPOINT,ROLLBACK TO, andRELEASE. Raw SQL savepoints inside a transaction work too. - Kysely schema statements that stay inside Minnow's supported DDL profile.
createTable().temporary()creates an ordinary table, as the engine reads it. A column's.autoIncrement()compiles to theautoincrementspelling the engine reads as its auto-increment key, as Kysely's SQLite dialect emits it;serialandgeneratedAlwaysAsIdentity()declare the same key. db.introspection: tables, views, columns, nullability, defaults, auto-increment, and logical types. ExactNUMERIC(p, s), JSON/JSONB, UUID, DATE, TIME, INTERVAL, arrays, and enum names are reported instead of being flattened totext.
Minnow has one logical connection, handed out through a first-in-first-out mutex: each statement
waits for the previous holder to release, and a transaction or stream holds the connection until
it commits, rolls back, or is disposed. A db-level query issued while a transaction is open
therefore queues until the transaction ends instead of silently joining it — but for the same
reason, awaiting a db-level query inside the transaction callback deadlocks, because it waits
for the connection the transaction holds. Use the callback's trx handle for every statement
inside the transaction, exactly as with Kysely's other single-connection dialects. db.destroy()
releases Kysely but does not close the caller-owned Minnow database or worker client; close that
driver separately when the application is done.
Limits
Minnow uses PostgreSQL-style SQL, but it is an embedded database rather than a PostgreSQL server:
-
getSchemas()returns an empty list. There are no catalogs or schemas to invent. -
Transaction access modes and isolation levels are rejected; Minnow has one snapshot model.
-
Transactions do not nest: a second
BEGINinside an open transaction is rejected, so use savepoints — throughdb.startTransaction()or raw SQL — for nested work. -
mergeInto(...).returning(...)is rejected with an explicit error before execution: Minnow'sMERGEreports an affected-row count but returns no rows, and silently yielding[]would hide that.MERGEwithoutRETURNINGexecutes normally. -
Parameters must be
boolean,number,string,Date, ornull; BigInt, byte arrays, and driver-native PostgreSQL objects have no Minnow parameter representation. Exact decimals accept numbers or strings as inputs and return lossless strings by default. JSON/JSONB also return strings by default; opt into native number/JSON results withresultDecoding. UUID, arrays,DATE,TIME, intervals, and enums cross this adapter as strings. -
Kysely can generate SQL outside Minnow's profile — PostgreSQL features Minnow lacks, and the MySQL, SQLite, and T-SQL spellings Kysely's builders also carry. The adapter's compiler refuses every such form it can see before execution, naming the feature and an alternative:
- Queries: SQL
EXPLAIN(use the Minnow database's ownexplain()), T-SQL.top(),.output(),.crossApply(), and.outerApply(), an emptyIN ()list (add Kysely'sHandleEmptyInListsPlugin), and the JSON path operators->$and->>$, which compile to a path string that Minnow's->would read as one member name (use->/->>with.key()and.at()). - Mutations: multi-table
updateTable([...])anddeleteFrom([...]), MySQLORDER BY/LIMITon updates and deletes,replaceInto(),onDuplicateKeyUpdate(), SQLiteorIgnore()/orAbort()and friends,ON CONFLICTwith a constraint or index-expression target (name the unique key's columns instead),MERGE ... THEN DO NOTHING(narrow theWHENcondition instead),MERGE ... WHEN NOT MATCHED BY SOURCE, and data-modifying CTEs (run the mutation first, then query). - DDL: schema-qualified names including
WithSchemaPlugin,CREATE/DROP SCHEMA,ALTER TABLEbeyond adding and dropping columns (renames, constraint changes, column alterations,ADD COLUMN IF NOT EXISTS), the T-SQLidentity()column modifier (useautoIncrement(), theserialtype, orgeneratedAlwaysAsIdentity()), inlineaddIndex()increateTable(),onCommit()on a temporary table and MySQL'sdropTable().temporary()(a temporary table is an ordinary table here; drop it withdropTable()), temporary views, materialized views, view column lists andCREATE VIEW IF NOT EXISTS, non-stored generated columns,DROP/ALTER TYPE,DROP VIEWandDROP INDEXwithCASCADE,dropIndex().on(table), index access methods (.using()), partial and expression indexes, expression unique constraints, any explicitDEFERRABLE/INITIALLYconstraint modifier, andNULLS NOT DISTINCT.
Anything else outside the profile is rejected explicitly by the engine — for one example, a range-correlated lateral subquery with
ORDER BY/LIMIT(rank with a window function instead) — with the executable feature matrix as the reference. - Queries: SQL
-
Rejected transaction settings are recoverable: a
startTransaction()refused for an isolation level or access mode releases its connection, and rolling back a transaction whose engine transaction is already gone — the state a failedCOMMITleaves — succeeds instead of wedging the instance.
Kysely reserves the connection during a migration, so one Kysely instance cannot race itself.
That does not coordinate migrations across tabs. For shared application startup, use Minnow's
schema declaration and database.migrate(schema), which coordinates through Minnow's catalog.
Compatibility checks
A full-surface conformance suite executes every supported Kysely builder form against the
engine with asserted rows and inferred types: inner/left/right/full/cross and lateral joins,
set operations, chained and recursive CTEs, ordering modifiers and FETCH FIRST ... WITH TIES,
CASE, row tuples, window functions, aggregate DISTINCT/FILTER/ordered STRING_AGG,
ON CONFLICT, MERGE, and DDL from composite constraints through enum types and sequences —
plus a refusal test for every named compile-time rejection above. Adapter tests further cover
DDL, RETURNING, commits, rollbacks, nested savepoints, connection serialization around open
transactions, and introspection. Minnow's broader
SQL compatibility suite compares supported PostgreSQL reads and writes with
PGlite and checks every documented difference and exclusion.