Live queries
Type-safe reactive SQL through Kysely, workers, keyed changes, and bounded windows.
Live queries re-run a SELECT only when a durable commit can affect it. The public typed primitive is adapter-neutral: Minnow tracks the SQL statement and its table dependencies, while the query adapter executes it. That split preserves the adapter's inferred row type and result plugins.
For Kysely, install the adapter and create one shared live-query manager. Pass the Kysely
instance as db: the wrapper then decodes results the engine delivers the way the dialect would,
its result decoding and every plugin's transformResult included, instead of executing the
statement a second time after each change.
import { createKyselyLiveQueries } from "@minnowdb/kysely";
const live = createKyselyLiveQueries({ driver: database, db });
const openOrders = db
.selectFrom("orders")
.select(["order_id", "customer_id", "total"])
.where("status", "=", "open")
.orderBy("order_id")
.$call(live);
const unsubscribe = openOrders.subscribe(() => {
const snapshot = openOrders.getSnapshot();
if (snapshot.status === "ready") render(snapshot.rows);
});typeof openOrders.$inferRow is the inferred Kysely row. A snapshot is one of:
loading, with an empty or last-knownrowsarray;ready, with immutablerowsand the manifestversionthat invalidated it;error, with the error and the last good rows retained.
getSnapshot() keeps the same object identity until state changes, and subscribe() returns a
synchronous cleanup function. Within a snapshot, a row that did not change keeps the object it had
in the previous snapshot, and when no row changed the rows array itself is kept. A renderer that
compares by identity — a memoized list item, a selector — therefore re-renders only what moved.
The same object works with framework external-store APIs and as an async iterable:
for await (const snapshot of openOrders) {
if (snapshot.status === "ready") console.log(snapshot.rows);
}Close individual queries when their owner goes away and close the manager at application teardown:
unsubscribe();
openOrders.close();
await live.close();Why the wrapper, not $call in core
$call is Kysely's composition method; it is not a shared query-builder standard. The reusable
piece is the callable wrapper: live(query) and query.$call(live) are equivalent. A Drizzle or
another query-library adapter can expose the same live(query) shape by supplying:
- the compiled SQL and parameters used for dependency tracking;
- an
execute(signal)function returning that library's inferred rows.
@minnowdb/core/live exports LiveQuerySource, LiveQueryManager, and createLiveQueryManager
for that purpose. The adapter continues to execute its own query, so name mapping and other result
transforms are not bypassed. A source may also supply decode(result), which maps a result the
engine delivers to the adapter's rows; with it, the engine delivers changed results itself and
execute is never called after registration. A library-specific composition helper may call the
wrapper, but is not required.
Keyed changes
When a UI needs patches rather than a replacement array, select a unique, non-null result key:
const orderChanges = live.changes(
db.selectFrom("orders").select(["order_id", "status", "total"]).orderBy("order_id"),
{ key: "order_id" },
);
orderChanges.subscribe(() => {
const snapshot = orderChanges.getSnapshot();
if (snapshot.status !== "ready") return;
for (const change of snapshot.changes) {
// insert | update | delete | move — all fully typed
}
});Key types are restricted at compile time to non-null string, number, boolean, or Date columns.
Duplicate or null keys fail at runtime instead of producing an ambiguous diff. Diffing is exact and
linear in the current row count: the previous snapshot's key index is retained, so each snapshot
is indexed once. A row whose values did not change keeps its previous object in rows, and the
update record for a changed row carries both objects.
Ordered, bounded windows
Large reactive result sets cost query time, comparison time, and worker transfer time. Prefer a bounded window for lists and dashboards:
const newestOrders = live.window(
db
.selectFrom("orders")
.select(["order_id", "placed_at", "total"])
.orderBy("placed_at", "desc")
.orderBy("order_id"),
{ key: "order_id", limit: 100 },
);window() requires a top-level ORDER BY, applies LIMIT and optional OFFSET, and refuses a
result above the declared limit. When the primary ordering can tie, include the unique result key
as the final ordering term so window membership and move patches are deterministic.
How invalidation works
Low-level subscriptions capture parameters and compiled plans when registration starts, including a private copy of Date parameters. Changing an input afterwards does not change the registered query; create a new subscription to use different parameters.
Broadcast messages are only latency hints. Every sweep reads the store's durable manifest and catalog probe, unions the tables changed by missed commits, and looks only at the subscriptions that read one of those tables: a commit to one table costs the subscriptions on that table, not a pass over every subscription in the set. The engine can also use block statistics to prove that some same-table inserts cannot affect a predicate. Equal low-level SQL subscriptions share execution and dependency work. Typed adapters share dependency/invalidation work; each typed store still executes through its own adapter so different result-plugin stacks cannot be merged incorrectly.
A typed query asks the engine to compare before anything reaches it. On a relevant commit the
engine brings the statement's result up to date where the data is, compares it with the rows it
delivered last, and delivers only when they differ. An adapter that can decode the engine's result
— the Kysely wrapper, given its db — receives that result directly: one execution or patch in
the engine, one transfer over a worker channel, and no round trip back to execute again. A commit
that changes a table without changing the query's rows costs one patch or execution inside the
engine and nothing else: nothing crosses the channel, no rows are rebuilt on the main thread, and
no component re-renders.
Incremental maintenance
A statement over one table with a unique key — filters, projections, ORDER BY, LIMIT and
OFFSET, but no DISTINCT, window functions, or subqueries — is not
re-executed when its table changes. The engine records which keys every commit touched; on a
relevant commit it re-evaluates exactly those keys through the ordinary executor, so predicates,
expressions, and ordering keep their SQL meaning, and patches them into the retained result: rows
that left are removed, rows that entered or changed are merged in order, and every untouched row
keeps its object. A commit that touches one row in a table of a million costs one keyed lookup
per subscription, not one scan.
A window (ORDER BY … LIMIT n) keeps a margin of rows beyond its visible edge, so a member that
is deleted or edited out of the window is replaced from rows already held. The statement runs in
full only when a patch would have to reach past what is held — the margin ran dry, an OFFSET
window's start moved, a commit changed more than 2,048 rows, or history no surviving segment
accounts for — and that execution refills the margin. LiveQueryStats.maintained counts patched
re-runs beside reruns; liveQueries({ incremental: false }) turns patching off for a set.
Whether a statement is maintained is decided from its shape, and the engine says so. explain()
ends with -- live: maintained incrementally on change or -- live: re-executes on change:
followed by the reason in plain terms ("2 joins; incremental maintenance supports one inner or
left join to a unique key", "subquery or EXISTS", "window function", "orders has no unique key").
stats.groups carries the same per statement — maintainable, reasons, and its own reruns,
maintained, and fallbacks counts — through the worker client, and the devtools Plan tab
shows the -- live: line, so a statement that re-executes on every commit is visible rather
than merely slow.
COUNT, SUM, and AVG can maintain per-key contributions, including grouped results. Exact
NUMERIC inputs retain decimal semantics. Plain numbers qualify for SUM/AVG only while their
integer values and absolute sum remain safely representable; other floating-point aggregates
re-execute so incremental rounding cannot change an answer. DISTINCT aggregates and other
aggregate functions re-execute.
Aggregate patches stage only changed contributions and publish them after result evaluation and memory admission succeed. A failed patch or an over-limit result leaves the previous contributions intact for retry. Contribution updates and their byte accounting do not scan every input row; grouped queries still rebuild their output groups. Ordered maintenance keeps SQL-domain values until comparison is complete, including exact NUMERIC ordering.
One inner or left join to a unique lookup key can patch base-table changes. A lookup-table change runs the statement again. More general joins also re-execute. The zone-map proofs above still spare these queries inserts that cannot match.
Memory
The engine retains one result per distinct statement, a key-position index, ordering values, and at most 64 margin rows for a bounded window. Aggregate maintenance also retains per-key contributions, so a small aggregate result can retain state proportional to its input.
liveQueries({ maxRetainedBytes }) bounds modeled resident results and maintenance state per
set; the default is 64 MiB. stats.retainedBytes reports that accounting. An over-limit opening
fails; a later over-limit result reports an error and keeps the previous good result. Closing
subscriptions releases their group's accounting. This is a modeled payload limit, not a browser
heap measurement.
Full immutable result arrays still require work proportional to the retained rows. Prefer
LIMIT and live.window() for scrolling lists. subscribePatches(query, { onPatch }) delivers
an initial reset and then changed row payloads plus a retained-position map, avoiding a private
full row-array copy per patched subscriber. Full executions deliver resets. Worker patch
subscriptions transfer only changed rows and retained positions after the reset, reducing wire
payload as well as consumer copying. Ordinary subscribe() and typed snapshots still deliver full
results. A patch consumer owns its reconstructed rows; the client keeps no additional full baseline.
A set executes at most eight statements at once.
Adapters without a decoder can reuse accepted maintained results from the ordinary result memo. Publication requires an unchanged durable probe and obeys the buffer pool and per-result caps; if a commit races publication or the result does not fit, the adapter executes normally. Supplying a decoder remains preferable because it avoids that extra request and memo copy.
Subscribing is cheap enough to do per component. A new statement resolves its dependencies and runs once; it needs no sweep, because the probe its dependencies were resolved under is what a sweep would read. Subscriptions opened in the same turn — a page mounting many components — share the two probe reads between them, and a set runs at most eight statements at once so a burst of subscriptions never exhausts the engine's read admission.
Catalog-only changes, including replacing a view, refresh dependencies before execution. A failed
query remains dirty so refresh() retries without requiring another commit. Results are compared
exactly, row by row, and an unchanged result is never delivered.
Established subscriptions treat OpfsCoordinationError as temporary unavailability: they keep
the last good rows, suppress onError for that failure, and retry automatically without another
commit or resubscription. One timer per set backs off from 100 milliseconds to a maximum of five
seconds; poll and commit hints coalesce during the wait. Closing the set cancels the retry.
This also covers failures while refreshing dependencies or executing a changed query. Query,
schema, corruption, and uncertain mutation errors still reach onError. Initial registration
can reject with the typed coordination error because it has no successful snapshot to retain.
Built-in IndexedDB and OPFS stores derive a cross-tab channel name from the database name. Set
channelName explicitly only to coordinate a custom store or naming scheme, and use
pollIntervalMs when an environment needs a bounded fallback without BroadcastChannel hints.
The low-level database.liveQueries() / client.liveQueries() API remains available for raw SQL
callbacks. Each onChange receives a private copy of the result; a set created with
sharedResults: true hands every subscriber the set's retained result instead, which the worker
host uses because it encodes the result for the channel synchronously. Its observe() form
reports invalidations without delivering rows; with suppressUnchanged: true the set executes the
statement itself and invalidates only when the rows changed, which is what typed adapters use.
A decoded typed query can load through refresh() before any listener subscribes, including
React Suspense's first render. It uses a temporary subscription and releases it after loading.
Decoder output is checked before reusing retained rows, so reordering and cross-row transforms
cannot attach a newer version to stale values. Consumer error and completion callbacks are
isolated so one throwing callback cannot stop other subscriptions or later sweeps.