Changelog

Release notes, migration steps, and storage compatibility for every Minnow release.

Release notes are listed newest first. Each entry names the packages published from that release commit; an omitted package did not change. Minnow is still in 0.x, so read an entry's migration notes before changing a pinned version. The versioning guide explains the compatibility policy and release process.

0.14.1 — October 8, 2026

Release packages: @minnowdb/core@0.14.1.

  • An OPFS database opens again after a crash during an index rebuild. A write from a tab that had not seen a new index yet marks that index invalid so it rebuilds, while the index's half-finished build stays recorded until its owner aborts or finishes it, or its lease runs out. A checkpoint taken in that window is consistent, but reopening the database refused it with StorageCorruptionError: … Staged postings build has invalid catalog ownership, and the database stayed unopenable. Opening now accepts a build whose index still exists, whatever its state, and still refuses one whose table, index, or column is gone. A database already stuck this way opens with 0.14.1 without any repair. Found by the nightly Firefox OPFS crash test.

  • Dropping a table no longer makes its compaction report a failure. When another tab dropped a table just before its automatic compaction started, the compaction reported Compaction table is missing through onBackgroundError. It now ends as cancelled, which automatic compaction treats as done; compactTableStep() raises CompactionJobCancelledError.

  • A fold abandoned by another tab as it begins reports a typed conflict. When another tab abandoned a shared fold after a schema change while this tab was starting that fold's transaction, the step failed with the other tab's recorded message, Schema changed during compaction, as a plain Error. It now raises CompactionJobConflictError with the job's ID and changed revision, as a fold abandoned at any other point between selection and execution already did, and the next step plans against the current schema.

  • A tab closing mid-maintenance is no longer reported as a failure. When the tab holding OPFS leadership closes or crashes during another tab's collection or compaction step, that step fails with OpfsUncertainOutcomeError. Maintenance jobs are durable and revision-guarded, and the retry reads the job back before stepping, so the engine now retries without reporting it through onBackgroundError. Collection still counts it in maintenanceStatus()'s consecutiveFailures and lastError. StorageUnresponsiveError and every other background failure are still reported.

Migration and stored formats: no application migration is needed, and no stored format changes: IndexedDB schema 4 and OPFS layout 9, as in 0.14.0.

0.14.0 — October 2, 2026

Release packages: @minnowdb/core@0.14.0.

  • A large upsert no longer breaks every query on its table. Upserting 316,179 or more existing rows in one statement made every later query on that table fail with QueryMemoryBudgetError, SELECT COUNT(*) FROM t WHERE id = 5 included, on every store and with default settings, and the table never recovered on its own. A query merging unmerged changes now keeps about 8 bytes per changed row (it was 148 to 212). Past a quarter of the query's memory budget it replays them one range of rows at a time instead of failing, and one such change is enough to queue an automatic fold. After a 1,000,000-row upsert, COUNT(*) takes 0.1 ms (was 1.1 s), SUM 32 ms (was 1.4 s), and SELECT * 0.6 s (was 2.5 s). compactTable() without memoryBudgetBytes now sizes its fold the way automatic compaction does, instead of refusing.

  • One transaction's key and index changes have no fixed limit. A single statement failed past 1,048,576 changes, or past 65,536 distinct values in one indexed column (Full-text change chunk exceeds the posting-count limit). Each store now persists those changes in bounded pieces: IndexedDB as part records (schema 4), OPFS as continuation frames in its log (layout 9), and an OPFS follower sends them to the leader as 8 MiB pieces. A 2,000,000-row write into a keyed table with two indexes, and an upsert of all of it, now succeed on every store, and a 400,000-row indexed write from an OPFS follower, which hit the message's structural limit, commits. On OPFS one commit's log frame must fit the log, now 1 GiB (it was 64 MiB per frame), and the database's metadata, every keyed table's keys among it, must still fit one 256 MiB checkpoint.

  • Large commits don't hold the thread. A large commit adds its keys to their memberships a slice at a time behind a mask, and committing drops the mask instead of touching each key; upserting keys that already exist publishes nothing. Its index changes are checked a slice at a time and kept without copying, a transaction merges each statement's changes once at commit, and the engine's remaining passes over a large batch (byte estimates, block planning, key lists, a term's row ids) yield between slices. On OPFS, checkpoints copy key memberships a slice at a time, opening a database checks large index changes a slice at a time, and recovery decodes a large log frame a slice at a time. Writing 500,000 rows and then upserting all of them holds the thread for at most 21 ms on the memory store (was 55 ms) and 22 ms on OPFS (was 83 ms).

  • OPFS reads don't wait behind large writes. Reader leases are logged between the slices of a large commit or checkpoint instead of after them: during a 500,000-row upsert a lease waited 370 ms, now under 1 ms. Index lookups read without the leader's queue and use it only when the chunks they read moved meanwhile, so they no longer wait for folds, checkpoints, or large commits.

  • An index change larger than one stored chunk is folded into its index's base right after its commit. An OPFS checkpoint that outgrows its slot meanwhile stops as soon as it passes it and is tried again once the fold has pruned the change, instead of encoding the whole state on every attempt.

  • IndexedDB DROP TABLE now also removes each indexed column's change index; earlier versions left it behind, and checkIntegrity reported it. An OPFS connection that closes while it reloads no longer keeps a file locked, which stopped any other connection from leading.

  • Storage contract and toolkit: MAX_TRANSACTION_COMMIT_DELTA_BYTES, MAX_TRANSACTION_COMMIT_DELTA_ENTRIES, and transactionCommitDeltaRetainedBytes are removed. A commit's index postings may be any length, and one posting's row ids may exceed a stored chunk's limit. New: seekFtsPostingQuery; the StorageResourceLimitError resource "log frame byte"; WalWriter.appendContinuation, appendEncodedSliced, and rewind; iterateWalFramesSliced; WAL_CONTINUATION_PIECE_BYTES; EncodedRecordTooLargeError and a byte ceiling on encodeSyncCheckpointSliced; RecordCore.discardPreparedCommit, dumpSliced, ftsDeltaPostingCount, and loadSliced(state, pause, { owned }). RecordCore.prepareCommit also takes ftsChanges, and RecordCore keeps the postings a commit hands it rather than copying them, so do not change them afterwards. DatabaseTransaction.setFtsChangesSliced (with { owned }), setUniqueKeyChangesSliced(changes, { distinct }), and largeFtsDeltaColumns are new.

Migration and stored formats: no application changes are needed. IndexedDB schema 3 upgrades to 4, and OPFS layout 8 to 9, automatically on open without rewriting stored data: IndexedDB raises its version, and OPFS rewrites its format marker behind a witness file. Older IndexedDB schemas and OPFS layouts 6 and 7 still upgrade too. After the upgrade, 0.13.1 and older refuse the database without changing it. Block format 2 and snapshot format 1 are unchanged. Storage-toolkit users: a log holding continuation frames is refused by an older toolkit reader, so gate older readers with your own format marker before writing them.

0.13.1 — October 2, 2026

Release packages: @minnowdb/core@0.13.1.

  • Opening an OPFS database parses its checkpoint a bounded piece at a time. The checkpoint was one JSON.parse call, about 1.5 ms per megabyte; the decoder now scans the bytes for structure and parses runs of up to 64 KiB natively, with turns in between. Opening a million keyed rows holds the thread for at most about 35 ms at a time, down from 94 ms.

  • Large writes no longer hold the thread for their whole commit. A commit with many UNIQUE keys checks them and checksums its new blocks a slice at a time before it commits, while the store holds its place so nothing else commits meanwhile; on OPFS it also encodes its log frame that way, and its preflight and its commit share one check of the keys. The engine's own passes over a large batch — the row-to-column pivot, unique-key accounting, index and full-text deltas, and block encoding — hand the event loop a turn between slices too. Writing and then upserting 800,000 rows on OPFS held the thread for up to 505 ms; it now peaks at 127 ms, most of it the one step a commit cannot split, publishing its keys at about 70 ns a key. At 500,000 rows the peak is about 55 ms on the memory store and 85 ms on OPFS.

  • A connection opening an OPFS database while another converts it to the current layout checks whether the conversion finished before judging its own patience. A long step elsewhere on the same thread could outlast that patience as the conversion completed, and the opener gave up with the "close older connections" error instead of opening.

  • MemoryOpfs, the in-memory OPFS for Node tests, resolves handles by path, as browsers do. A handle to a removed directory kept seeing the removed files, so an OPFS connection that opened alongside a layout conversion could keep reading the finished conversion's leftovers and wait until it gave up. Browsers were never affected; the shim now throws NotFoundError there, and a handle sees new entries once its path is created again.

  • Storage toolkit: RecordCore.prepareCommit(input, pause) does that work ahead of a commit, which reuses it while no membership has changed; WalWriter.appendEncoded(bytes, flush) appends a frame encoded beforehand; DatabaseTransaction.setUniqueKeyChangesSliced records a large key list a slice at a time.

  • The transactions guide now states the commit-delta bound: one transaction's key and index changes may hold at most 1,048,576 entries and 64 MiB, which a single 500,000-row write into a keyed table with one secondary index reaches.

Migration and stored formats: no application migration is needed, and no stored format changes: IndexedDB schema 3 and OPFS layout 8, as in 0.13.0.

0.13.0 — October 1, 2026

Release packages: @minnowdb/core@0.13.0.

  • Compaction no longer stalls, whatever order rows arrive in. A fold's job record now holds its sources, output windows, and partitions, but not which source row feeds each output cell: that replay is recomputed from the immutable sources, kept in memory as compact runs, and checked against a checksum before any block is written. Before, upserts replacing rows in an order unlike the table's stored a range per cell and rewrote it on every step — a fold of four refreshes of 7,000 forty-column rows took 15 minutes and held the thread for 700 ms at a time. It now takes under two seconds, in slices of about 20 ms. A fold resumed in another tab or after a reload recomputes the replay; one that no longer matches its plan is abandoned unwritten and planned afresh.

  • Background work and long statements run in slices of a few milliseconds and hand the event loop a turn between them: fold planning and execution, live-aggregate updates, index and full-text builds, OPFS checkpoints, query scans, the first lookup through a new index, and the per-row passes of large writes. One clock paces every slice, so a statement made of many short steps, or statements awaited back to back on OPFS, yield as often as one long loop. Across the measured shapes — wide tables refreshed in any order, hundreds of thousands of rows of point updates or deletes, index builds, and live aggregates under heavy writes — the longest block during background work is about 40 ms on a laptop, on the memory and OPFS stores alike. The worst of them held it for 250–700 ms before. npm run benchmark:stalls measures it (add -- --store opfs for the native store), and event-loop-stalls.test.ts keeps it bounded.

  • CREATE INDEX over 200,000 rows held the thread for half a second: the build sorted and wrote each 65,536-row block's postings in one turn, and the first lookup then hashed every stored key. A block's terms now sort in bounded runs that merge with turns in between, writes yield as they go, and the lookup yields while it hashes; the longest block is now 15–25 ms. Blocks stay the unit a chunk's terms come from, so a lookup still reads one chunk per block.

  • Creating a UNIQUE index stages its key set through the store's chunked build — ordered chunks of at most 4,096 keys — and publishes it with the ready index in one atomic step, instead of sending every key in one catalog update. On OPFS a million-row UNIQUE index no longer writes one log frame of every key, and no step holds the thread for more than about 30 ms. Staged chunks grow in place rather than being copied per chunk, and an IndexedDB build over an empty table now publishes an empty key set that later reads accept.

  • Indexes build over tables of any size. Posting chunks held 128 postings each and a base may hold 4,096 chunks, so a secondary index over more than about 524,000 distinct values, or a full-text index over a few hundred thousand documents, failed to build on every store: CREATE INDEX was refused, and full-text search fell back to scanning. Chunks keep 128 postings until a build nears the limit and grow from there.

  • OPFS checkpoints — when the log fills, before collection deletes a drained data file, and at shutdown — encode and write a slice at a time while holding the write queue, so reads keep answering between slices and the state they publish cannot change. Their bytes are exactly a one-step checkpoint's. Loading a million keyed rows into OPFS 50,000 at a time held the thread for up to 161 ms; it now peaks at 77 ms, the writes themselves.

  • Opening an OPFS database of a million keyed rows with a UNIQUE index held the thread for 4.5 seconds in one block. Recovery decoded both checkpoint copies through a per-value JSON reviver, validated the state twice, and built every key membership twice. It now parses once and converts tagged bigints in one pass, reuses the first copy when the mirror holds the same bytes, skips the second validation when no log frame was replayed, and builds each membership a slice at a time: the open runs in steps of under 100 ms. The storage toolkit adds decodeSyncCheckpointSliced and RecordCore.loadSliced.

  • Full-text matching tokenizes each distinct stored value the first time a scanned row uses it, instead of tokenizing a block's whole dictionary on its first batch, which held the thread for about 60 ms per block of distinct documents.

  • Automatic compaction no longer stalls on keyed tables with wide rows. Planning a fold takes memory in proportion to the distinct keys its deltas touch, so refreshing the same rows again and again fits the default 32 MiB budget however wide they are. Automatic folds fit themselves to the budget with no new option: a fold that does not fit is cut to fewer level-zero segments and planned again, and the smallest fold a table allows is given the memory it needs. A failed attempt backs off on time alone instead of waiting for the table to double its segment count.

  • Deltas that hold as many rows as the data they change, once past 4,096 rows, now schedule a fold, so a table refreshed wholesale folds after about two refreshes instead of thirty-two.

  • Folds of tables with scattered point updates or deletes plan output windows from each source block's size counted once. A block read by many ranges was counted once per range, which split windows down to a few rows: folding a 200,000-row table after forty update statements took 59 seconds and wrote 3,828 blocks. It now takes half a second and writes 52.

  • Scans of a table carrying many upserts visit each window's own patches instead of every patch for every window, and live COUNT/SUM/AVG aggregates add integer contributions as numbers rather than decimal strings. Writes under a live aggregate are about twice as fast.

  • Automatic compaction reselects active work after yielding, so another tab's schema change can abandon a stale fold without causing an untyped background failure. A job abandoned between selection and execution reports its job ID and changed revision. Explicitly resuming an already aborted job still reports its recorded failure, and unexpected I/O still propagates.

  • Storage contract: CompactionRewritePlan adds ReplayedMergeCompactionRewritePlan (kind: "merge-v2") with MergeCompactionPlannedColumn and MergeCompactionResolution; MergeCompactionPlanLayout holds the fields both merge kinds share, and isMergeCompactionPlan tells them apart. RecordCore.installValidatedFtsBase installs a postings base whose chunks were validated as they were staged. The memory store's full-text build no longer revalidates and copies the whole base when it finishes. The storage toolkit adds encodeSyncCheckpointSliced(state, pause), encodeSyncCheckpoint's exact bytes encoded a bounded piece at a time.

Migration and stored formats: no application migration is needed. IndexedDB moves to schema 3 and native OPFS to layout 8; both upgrade automatically on open and change no stored data, since the new version only admits compaction jobs with replayed merge plans. A layout-7 OPFS database has its format marker rewritten behind a small flushed witness, so an interrupted upgrade finishes on the next open. Minnow 0.12.x and earlier refuse the upgraded database without changing it, so pin the new version everywhere that opens the same database. A compaction job left in flight by an older version resumes and publishes as planned. Block format 2 and snapshot format 1 are unchanged. Adapters built on the storage toolkit that persist compaction job records may now be handed merge-v2 plans; version their own stored formats accordingly.

0.12.1 — October 1, 2026

Release packages: @minnowdb/core@0.12.1.

  • Catalog table/view creation and destructive changes now have a dedicated internal owner for dependency checks, bounded conflict retries, and atomic publication. Writer admission owns its waits, compaction turn lending, and maintenance shutdown. Query preparation and execution own their compilation caches, streaming fallback, spill handling, and memory cleanup.

  • IndexedDB and the memory/OPFS record engine share commit freshness checks and level-zero admission and table accelerator planning. Adapter transactions, block visibility checks, and durability stay native.

  • Bulk unique-key removals update membership immediately and rebuild snapshot order only when needed, with a direct append path for the sorted rebuild. Batched writes index their block metadata once rather than searching the batch for each referenced block. IndexedDB queues independent commit reads together instead of waiting for each request group separately. Validation, checksums, and atomic publication remain in place.

  • Documentation and benchmark labels now distinguish measured downloads from worker bundles, identify comparison-driver coverage, and describe competitors’ live queries, multi-tab access, and persistence accurately. Broad performance claims are replaced with workload-specific guidance.

Migration and stored formats: no application or data migration is needed. Public APIs, block format 2, snapshot format 1, IndexedDB schema 2, and OPFS layout 7 are unchanged. Automatic upgrades from the supported older formats remain in place.

0.12.0 — September 30, 2026

Published packages: @minnowdb/core@0.12.0, @minnowdb/devtools@0.3.4.

  • coordinateWrites is removed, as announced in 0.11.0. Delete this ignored option from engine and worker-client initialization. Writers still take turns automatically.

  • JSON/JSONB preserves every numeric digit from JSON text through persistence, extraction, constructors, and comparison. ARRAY text refuses numeric input that would lose precision. Exact NUMERIC DIV and TO_CHAR avoid Float64 conversion.

  • Empty-subquery IN/NOT IN, timestamp validation and years below 100, TEXT comparisons, SQL quoting, and positional FORMAT now return correct results or explicit errors.

  • Regex matching uses a bounded interpreter with leftmost-longest selection and ASCII POSIX classes. Unsupported pattern backreferences, lookaround, inline flags, and non-greedy quantifiers are refused instead of running in the host regex engine.

  • Lazy index and maintenance failures reach their diagnostic hook or console.error. Throwing hooks cannot prevent worker failure cleanup. Malformed worker result frames are rejected before allocation; use cursors for results above 1,000,000 rows or 4,000,000 cells per frame.

  • Writer stall reports require an observed holder. An empty or unavailable Web Locks snapshot cannot invent a stalled writer, and only the local queue head inspects remote locks. Observed holders still trigger the ten-second diagnostic without being bypassed.

  • Benchmarks remove sample tables, await database cleanup, use SQLite's public OPFS constructor, and cannot count execution failures or missing read/write engines as passing. npm run benchmark:compare gives every engine the same primary-key indexes; historical regression thresholds remain separate.

Migration: OPFS now writes layout 7, with an independent checksummed WAL acknowledgement boundary. Layout 6 upgrades automatically on open using a validated temporary copy and crash-resumable publication. No export/import is required; allow temporary quota for the copy and close older connections if they block ownership. Conversion refuses damaged or ambiguous old state. Keep snapshots: a torn acknowledgement slot is reported as corruption and stops recovery. Block format 2, snapshot format 1, and IndexedDB schema 2 are unchanged. Regex patterns using the refused forms must be rewritten.

  • Maintenance scheduling now has separate owners for collection and compaction timers, retries, debt and task draining. A bounded background failure history supplements diagnostic hooks. Native adapters share transaction refusal rules while retaining their atomic storage mechanics.

  • Concurrent compaction registers jobs under a short writer turn, reconciles superseded sources only after verifying durable job progress, and keeps ready jobs ready during resume. Automatic folds replan after a schema conflict and stop cleanly when another tab drops the table or cancels the job. Unrelated I/O failures still propagate even if another tab publishes or cancels concurrently, or a job transition persisted before failing. Failure-recording I/O reaches the diagnostic hook without replacing the original failure. A posting build racing an index drop returns the same typed ownership conflict on every adapter.

  • Foreground entry assists one compaction claim instead of draining an unbounded stream of arriving maintenance. A claim that receives a writer turn cancels its redundant admission wait, preventing completed work from reporting a false stall. Backpressure and real stall reporting remain active; unexpected admission failures still reach diagnostics.

  • IndexedDB batches visible segment-owner checks within the publishing transaction. Every owner is validated, shared owners retain their full segment count, and corruption still aborts the commit atomically. Bounded segment discovery avoids per-record cursor turns for small table partitions; a full batch falls back to the exact cursor path. Block visibility still uses fresh point reads. The connection watchdog coalesces progress timers while preserving the silence deadline and shared failure identity. UNIQUE probes start their cursors inside the selected source, retaining ordered add/remove replay and corruption checks. No stored format or safety limit changes.

  • Generated semantic equivalence checks also fix typed NULL result-domain loss and negative zero in INTEGER casts. These are query-result fixes; stored formats are unchanged by this refactor.

  • Comparison reports enforce declared workload coverage and expose attempted/verified/failed counts. Small-sample percentiles are documented as sample maxima, not production tail estimates. Prepared-statement cleanup is awaited; unexpected PGlite deallocation and database-close failures invalidate the run, and paired execution/cleanup failures retain both errors.

  • Compaction transitions validate external plans once and retain a private validated copy within the transition. Bound column/literal projections avoid repeated general expression dispatch. Result and corruption checks remain in place. The settle report separates observed work from its confirmation window while preserving the gate's full elapsed-time measurement.

  • Bounded numeric and timestamp ordering keeps its cut line and arrival counter local to each batch, and combines source/window bounds before scanning. NULL ordering and tie handling retain the ordinary sort semantics.

  • Keyed UPDATEs can reuse bounded point reads before evaluating assignments through the ordinary query kernel. Constraints, triggers and commit checks retain their usual behavior. These query changes add no cache or stored-format change.

  • Correlated lookups with a proven base-table key predicate materialize only those keys, then evaluate joins and remaining predicates normally. Ambiguous aliases and full-text queries retain ordinary preparation.

  • Numeric and timestamp point reads skip unrelated mutation blocks using their durable key bounds, including single-block segments. Header inspection and decoded history remain bounded; long or relevant histories fall back to ordinary replay. Deletes and reinserts retain their usual snapshot semantics.

  • Point reads preserve result aliases named __proto__ as ordinary own properties through append, mutation and compacted histories. SQL writes, defaults and columnar batches also preserve columns with that name instead of invoking JavaScript's inherited setter.

  • INSERT accepts DEFAULT for stored generated columns. Explicit values, including mixed batches containing an explicit generated value, are still refused before any row is staged.

  • Published core JavaScript compacts whitespace without renaming identifiers. Declaration documentation stays available; stored formats and application bundles are unchanged.

  • The documentation editor uses patched DOMPurify 3.4.16 through Monaco. Build and test dependencies also receive security patch updates.

0.11.1 — September 27, 2026

Published packages: @minnowdb/core@0.11.1.

Migration: no application or data migration is needed. Code that drives collection itself with collectGarbageStep() and resumeGarbageCollectionJob() can now also receive GarbageCollectionJobConflictError when another tab finishes the job during a step. Handle it like Garbage collection job not found: call collectGarbageStep() again.

Stored formats and the wire protocol are unchanged. Databases remain compatible with 0.11.0.

Fixed

  • Garbage collection no longer fails when another tab finishes the same job first. Two tabs can step one collection job. When the other tab completed the job and removed its record before this tab's step landed, the step failed with Garbage collection job not found. Background collection reported it through onBackgroundError, and collectGarbage() threw it. Background collection now treats the job as done elsewhere, whether the record disappears during a step or between two steps. collectGarbage() plans again. collectGarbageStep() and resumeGarbageCollectionJob() raise GarbageCollectionJobConflictError in that case instead of a plain Error. Calling resumeGarbageCollectionJob() with an unknown job ID still raises Garbage collection job not found.

Testing

  • garbage-collection-step-race.test.ts removes the job record just before and just after the collector's step, for both collectGarbage() and background collection. Each case fails without the fix.
  • The nightly performance gate's Linux threshold for delta-count-star now covers GitHub's slower runner class, where it was flagged before and after 0.11.0 alike. The query's cost is unchanged: 37.6 µs before 0.11.0 and 37.4 µs after, measured side by side.

0.11.0 — September 17, 2026

Published packages: @minnowdb/core@0.11.0.

Migration: a database now has one writer at a time across engines, workers, and tabs. Every write() scope, batch write, SQL mutation, BEGIN transaction, migration, DDL statement, compaction publication, and snapshot import takes its turn — a queue per store in each context, plus the Web Lock minnowdb-write:<store> across contexts — before reading the state it depends on, and holds it until its outcome is known. Remove any application-side write serializer or contention-retry loop: writers issued at once from any number of tabs land in a serial order with no WriteConflictError or SchemaConflictError between them. Those errors now only come from a writer that does not take turns (an older build in another tab, or a custom store with no liveQueryChannelName opened in another context), where storage compare-and-swap still refuses it. Inside a callback, use the supplied tx: a write on the same database awaited from inside the callback waits on the callback itself, which the engine reports through onBackgroundError (WriteAdmissionStalledError, context write admission) after ten seconds and otherwise waits out. Callbacks are never replayed.

  • coordinateWrites is deprecated and ignored; it is removed in 0.12.0. There is no optimistic mode to select, and the ten-second lock bypass it governed is gone: a holder that stops is reported, never overtaken, and the wait ends when the holder finishes, the browser releases its lock, or the waiter is cancelled. The provisional serializeWriteScopes option from the unreleased tree is removed.
  • write(callback, { signal }) cancels a scope still waiting for its turn (its callback never runs) or aborts one already inside. Closing an engine or a worker client rejects every write it still has waiting without waiting for the tab that holds the turn.
  • db.writeCoordination and client.writeCoordination() report how far the turn reaches: cross-context, context (no Web Locks), or instance (no store identity).
  • SQL BEGIN takes the turn before its snapshot and keeps it through COMMIT, ROLLBACK, the idle rollback, or close, so a transaction is no longer optimistic against other connections. Other writers on the same database wait for it; the idle timeout still bounds an abandoned one.
  • migrate() and each DDL statement take one turn for their whole validation and publication. CREATE INDEX marks the index in one turn, builds outside any, and publishes readiness in another, so a long build never holds every other writer. A cooperating writer is no longer invalidated by a concurrent schema change.
  • Compaction, garbage-collection reconciliation, and background index builds publish as writers; a fold's publication takes a turn of its own or rides inside the next writer's. A write at the level-zero ceiling lends its turn to the fold it steps instead of waiting behind it. Closing grants background maintenance two seconds of turns before cancelling what is still waiting; the work stays resumable in its job record.
  • Collection assistance and debt backpressure run before a writer takes its turn, so a writer never holds the turn while waiting for maintenance that needs it.

Stored formats and the wire protocol are unchanged. No data migration or rebuild is required.

Regression coverage: 45-table bursts at concurrency 1, 6, and 12 over memory, IndexedDB, and the OPFS shim through direct engines and worker clients with zero commit retries; three real tabs bursting scopes, batch writes, SQL statements, and BEGIN transactions over one IndexedDB or OPFS database while one tab adds a column and an index, with zero conflicts; a scope held open in one tab while another tab's write waits and a closing tab lets go at once; the writer queue against real Web Lock holders that progress, freeze, or are cancelled; and the interaction simulator asserting zero lost commit races in Node and across real tabs.

0.10.4 — September 17, 2026

Published packages: @minnowdb/core@0.10.4.

Migration: no application or data migration is needed. Existing databases benefit from the replay fix after upgrading; no rebuild or manual compaction is required to apply it. The default 64 MiB query budget is unchanged, and replay state that exceeds it still rejects.

Stored formats are unchanged: OPFS layout 6, IndexedDB schema 2, block format 2, snapshot format 1, worker protocol 7, and secondary-index term encoding tuple-v2. Databases remain compatible with 0.10.3.

Fixed

  • Repeated upserts no longer retain every historical row during streamed replay. Queries read key blocks in bounded groups, track each touched key once, and discard patches whose columns have all been replaced. This avoids exhausting the query memory budget on redundant history while the live table stays small, including after reopening the database. The bitmap that tracks removed scan rows still grows with history; compaction reduces that history.
  • Temporary mutation patches release their memory charge between scan windows. A long scan no longer accumulates charges for patch data that earlier windows have already discarded. Partial updates, deletes, reinserts, row order, and query results are preserved.

Testing

  • Regression tests cover repeated upserts with cold and cached replay, reopening, partial updates, deletes and reinserts, and scans across many patched windows under finite budgets.

0.10.3 — September 14, 2026

Published packages: @minnowdb/core@0.10.3.

Migration: no application or data migration is needed. Automatic storage selection now rejects an unexpected error while checking for an existing database. Handle that error as a failed open; retry once the storage problem is resolved.

Stored formats are unchanged: OPFS layout 6, IndexedDB schema 2, block format 2, snapshot format 1, worker protocol 7, and secondary-index term encoding tuple-v2. Databases remain compatible with 0.10.2.

Fixed

  • The TypeScript console preserves nested compiler diagnostics. Errors now include the underlying invalid column or argument instead of stopping at a generic overload message.
  • Equivalent join orders reuse the same catalog snapshot. Metadata cache entries now use a canonical table-name order. This avoids repeated IndexedDB catalog and segment scans when queries join the same tables in different orders. SQL column order, snapshot freshness, durability, and query results are unchanged.
  • A failed existence probe cannot select an empty replacement database. OPFS permission, I/O, and layout errors now propagate instead of being treated as a missing database. The IndexedDB fallback probe also propagates unexpected open failures. This prevents { kind: "auto" } from opening the other adapter when it cannot establish whether existing data is present. Native-browser regressions verify that existing storage survives the failed probe.
  • Automatic store choices wait for their IndexedDB transaction to commit. A request that succeeds inside a transaction that later aborts can no longer authorize opening the chosen adapter. Removing a remembered choice also waits for its durable transaction outcome.
  • Worker keepalives respect their timeout cap when the system clock moves backward. The client measures elapsed RPC time with a monotonic clock, so progress messages cannot extend a stalled request indefinitely after a wall-clock adjustment.
  • Garbage collection tolerates a concurrent table drop during planning. If a segment disappears between discovery and nomination, the planner verifies that exact segment is absent and retries. It still rejects candidates without valid provenance instead of hiding unrelated storage errors.
  • CREATE SEQUENCE works on IndexedDB. Sequence creation now records its generated-value column as the unique key, satisfying the adapter's durable catalog validation. Native-browser regressions also verify that sequence values continue after closing and reopening the database.
  • A busy queue of tabs keeps its write coordination. Admission now observes changes of lock holder before treating a long wait as a frozen tab. Previously, a healthy queue could trigger the fallback and make concurrent writers exhaust their commit retries. Native Web Locks tests cover a queue that advances beyond one admission interval; frozen-holder and RPC bounds remain.
  • OPFS reopens valid checkpoints after an index-build lease renewal. Recovery validates the current lease instead of measuring it from the build's original creation time. Renewals also keep their timestamps consistent when the wall clock moves backward. Previously, a valid checkpoint could be rejected and recovery could report a WAL gap or broken manifest chain.
  • An interrupted no-op shutdown checkpoint remains recoverable. Adjacent mirror generations with the same WAL sequence preserve acknowledged state. Different sequences without a WAL bridge, invalid generations, and corrupt payloads still fail closed.
  • Concurrent compaction reconciles publication during transaction resume. If another connection commits the same compaction while a coordinator renews its owner, the coordinator reloads the durable result. An ownership error without a durable state change still fails.
  • Manifest cleanup preserves the stored predecessor chain. Each bounded cleanup removes the oldest pruned prefix. OPFS replay chooses the same records as the live operation without relying on an in-memory cursor. Previously, an interrupted cleanup could make a valid database fail recovery. IndexedDB cleanup also preserves valid intermediate states and recognizes the exact unfinished cleanup range recorded by earlier releases during integrity checks. Already-invalid OPFS checkpoints still fail closed and require a known-good snapshot; the fix does not infer missing manifest history.
  • Graceful worker disposal reports progress before its deadline. Disposal uses its own heartbeat pace, so a responsive worker can finish closing after the five-second silence deadline. The existing ten-deadline absolute cap still terminates a stalled close.
  • Closing an already-failed worker client finishes local cleanup. The original fatal error remains the outcome of the failed call. Closing releases listeners and terminates the worker when requested instead of attempting an impossible disposal call and repeating that error. A new failure during graceful disposal still rejects the close.
  • SQLLogicTest rejects malformed result rows. A missing or undefined result field can no longer pass as SQL NULL, and unexpected result fields fail the harness.

Testing

  • TypeScript console checks now target the compiler's diagnostic output and verify the logged mutation result exactly. Matching text in the editor can no longer satisfy an output assertion or make it ambiguous. The result panel has an accessible name, and compiler errors use an alert.
  • Interaction plans finish with a fresh-row insert for each configured fault point, followed by a full checkpoint. Random fault steps can target missing rows and perform no write; these additional probes ensure that supported write hooks are actually reached. The randomized plan remains intact, and seed 1349451771 permanently covers the gap found by the soak.
  • Native SQLLogicTest runs report progress every thirty seconds, including the active SQL, completed operation counts, and statement and query timings. Progress is retained in CI logs and report attachments, so a timeout identifies where the corpus stopped. The full corpus, recorded answers, and ten-minute per-file deadline are unchanged.
  • The maintenance stress suite runs in a separate execution group after the other unit files. This prevents the expanding simulator corpus from consuming its CPU time under coverage. A single run still covers every test and combines their coverage, with the same workloads, assertions, and deadlines.
  • Native SQL comparisons initialize their Wasm oracles sequentially to reduce startup memory pressure. Traces record each initialization phase to locate any future page-process failure; the query corpus and required comparisons are unchanged.
  • Native checkpoint regressions reproduce both recovery defects in Chromium, Firefox, and WebKit. Failed interaction plans retain their original error chain, failing step, SQL history, and browser database files. Library traces omit continuous captures of blank runner pages; query counts, interaction lengths, assertions, and deadlines are unchanged.
  • A controlled compaction interleaving runs against real IndexedDB and OPFS in all three browsers, alongside the recorded long interaction seed that exposed the race.
  • Native regressions compare manifest lists before termination and after WAL replay, reopen intermediate cleanup checkpoints, and exercise slow worker disposal in all three browsers. Each reproduces a failure against the previous engine. The campaigns retain their original deadlines and reject unrelated errors.
  • Failed native tests preserve OPFS evidence before browser teardown. WebKit's storage is isolated under the test profile, and a bounded browser-API copy records raw files, byte counts, hashes, and any capture errors. Native tests verify that saved bytes survive context closure.
  • Every SQL feature-matrix entry now executes in all three real browsers over IndexedDB and OPFS, with explicit result-comparison and acceptance categories. Additional fixed and seeded reads and mutations compare against browser-native SQLite Wasm and PGlite with maintenance on.
  • Fixed behavior probes check mutation post-images, trigger effects, constraints, defaults, generated columns, sequences, identities, and transactions. An accepted statement or an affected row count alone can no longer satisfy these probes.
  • The fault sweep now checks exact whole-statement states and preserves every acknowledged write. Partial batches and lost committed rows fail. The same sweep runs in dedicated workers over IndexedDB and OPFS in Chromium, Firefox, and WebKit.
  • In-engine SQL comparisons use exact values; only external database comparisons retain their documented numeric normalization. Non-finite numbers stay distinct from NULL, text that looks like JSON stays text, and output column arrays are compared without ambiguous separators.
  • Simulator oracles reject malformed result rows and unrelated errors. Expected schema, uniqueness, transaction, injected-fault, and worker-crash refusals are checked by specific identities; an unresponsive store can end a campaign only during deliberate crash recovery.
  • Block-format properties run in real browser workers with the same seeded generators and assertions as Node, covering both codecs, exact round trips, pruning metadata, and corrupt bytes. The seed registry and soak runner now include this campaign.
  • Release and nightly gates run the full pinned SQLLogicTest corpus and longer interaction campaigns against real browser storage. Ordinary interaction plans must complete; deliberate worker-crash campaigns are separate. Plans are attached for replay, and nightly runs explore new recorded seed inputs. WebKit conformance runs on macOS, and unavailable worker OPFS fails the release profile. Publishing requires the exact commit's successful CI and conformance runs.

0.10.2 — September 13, 2026

Published packages: @minnowdb/core@0.10.2.

Stored formats are unchanged: OPFS layout 6, IndexedDB schema 2, block format 2, snapshot format 1, worker protocol 7. One catalog field gains a value: a secondary index's termEncoding is now tuple-v2 instead of tuple-v1. Both are readable, so upgrading needs no migration and every existing index keeps working untouched. Downgrading does not work once an index has been created or rebuilt on 0.10.2: 0.10.1 and earlier accept only tuple-v1 and report a tuple-v2 index as catalog corruption, so drop such indexes before pinning an older version. Two 0.10.2 tabs, and a 0.10.1 tab reading a database whose indexes all predate this release, still share an origin.

A testing audit added an interaction-plan simulator (below) and ran the SQLLogicTest corpus inside real browsers. The simulator's first soaks found the engine and adapter defects fixed here, each reduced to a deterministic test in simulator-regressions.test.ts.

Fixed

  • An index-pruned scan no longer returns deleted rows or stale values. When a secondary index narrowed a scan to an exact set of rows, the executor swept across the unselected rows between them as one batch. A row deleted or updated before the index was built has no delete or update segment among the pruned candidates, so it was read back from its original block: a DELETE followed by CREATE INDEX resurrected the deleted row on every indexed range query, and an UPDATE followed by CREATE INDEX served the pre-update value. The scan now visits exactly the selected rows.

  • Block-level index pruning keeps every delta. A set-operation member, or any scan the index narrows to whole blocks rather than exact rows, replays deltas over every row of a kept block. Dropping the delta segments whose keys were not candidates left a row patched by an earlier update but not by the later one that superseded it, and that stale intermediate value could satisfy the predicate: a UNION ALL member returned rows a later UPDATE had moved out of its range. Every update and delete segment now stays in a block-level pruned scan.

  • A composite index no longer skips rows with a NULL trailing column, and now indexes them. tuple-v1 postings exist only for rows whose every indexed value is non-null, so an index on (a, b) named no row with b NULL. A lookup on a alone that pruned through it dropped those rows: a SELECT missed them and an UPDATE reported one row too few, with no error and an EXPLAIN that said the index was pruning. The new tuple-v2 encoding gives a NULL component a one-character marker, so the row is named under its non-null prefix and a prefix lookup finds it. Every non-null component is byte-identical to tuple-v1. The marker sorts outside every real value of that column, so an equality, IN, or range on it never matches a NULL, as SQL requires. A NULL in the leading indexed column stays unindexed: no such predicate can match it.

    UNIQUE semantics are unchanged and still PostgreSQL's: a row with a NULL in any indexed column does not participate in uniqueness, so any number of them coexist. Those rows now carry a posting for pruning, but never join the membership set.

    An index written by an earlier version genuinely omits its NULL-component rows, so a prefix lookup that leaves nullable trailing key columns unconstrained is refused over it and answers from the ordinary scan — correct, just not accelerated. Its equality and range lookups, which no NULL can match, still prune. DROP INDEX then CREATE INDEX rebuilds one under the new encoding and restores prefix pruning; an index created on 0.10.2 has it from the start. A staged writer that still encodes tuple-v1 terms into a tuple-v2 index is now rejected at commit rather than leaving NULL rows out of the postings the planner prunes with.

  • A transaction that writes two segments to one table can be compacted. A SQL transaction holding, say, a predicate DELETE and an INSERT on the same table commits two segments with one logical order and one committed version. The merge planner ordered its sources by segment id as the tie-break while readers order them by position within the commit; whenever the two disagreed, every fold of that table failed with "Mutation compaction sources are not in canonical logical order" and the table could never be compacted again. The planner now orders sources exactly as readers see them, and the stored plan's validation checks the commit-level order it can see.

  • Two tabs no longer write one primary key twice through IndexedDB. The adapter keeps a per-instance cache of a table's unique keys and moved it to each new manifest version when a commit touched another table, as if nothing else could have happened in between. Another tab's commit to the cached table in that gap was invisible to it, and the next multi-row INSERT was checked against a key set missing those rows: it was accepted, and the table then held two rows with one primary key. The cache now follows a commit only when it was current at the version the commit was built on, and is dropped otherwise.

  • A deleted-and-reinserted key no longer makes reads and writes fail. Zone-map pruning and index pruning both dropped delete segments, or blocks of them, whose keys could not match the query, but the streamed replay maps keys to base rows and relies on those deletes to unmap a key before a later insert maps it again. Once the reinserted row shared a kept block with a matching row, every keyed range SELECT, UPDATE, and DELETE on the table -- and every indexed lookup after a range delete -- failed with "Stored table contains a duplicate unique key" until the table was compacted. Deletes are now always replayed whole.

  • compactTable() retries when the database moves under a fold. A background index build finishing, or another connection's DDL, moves the schema epoch while a fold is in flight, and a concurrent fold can replace a job's sources or win its publication races; each refusal surfaced to the caller, and a job refused for a schema change stayed active to be refused again. Such a job is now abandoned and compaction plans a fresh one, retrying the way writes already do.

  • A collection job that finds nothing no longer blocks every later one on IndexedDB. A discovery pass that ends with nothing to reclaim completes its job through the planning update rather than a reclamation step, and that path never released the adapter's single active-job admission. Every later collectGarbage() on that origin, from any tab, was then refused as conflicting with the finished job. Both completion paths now release it, and the block-store conformance kit checks that a second job can follow a discovery-completed one.

  • CREATE INDEX no longer fails because another connection moved the table record. Marking the index as building raced folds and index builds on other connections for the table's revision and surfaced TableRecordConflictError; the marking is now re-based on the fresh record.

  • Two tabs collecting garbage at once is a conflict, not a corruption. The IndexedDB adapter refused a second active collection job with StorageResourceLimitError, which collectGarbage() surfaced to the caller while another tab's background pass was running. It now raises GarbageCollectionJobConflictError like the memory and OPFS adapters, which the engine catches to continue the job already in flight -- or, when that job finished in the meantime, to plan again. Two tabs stepping one job also no longer trip each other: collectGarbage() picks the job up at its current revision instead of failing, and background collection treats another tab's progress as work done rather than reporting a failure through onBackgroundError. The block-store conformance kit pins the adapter behaviour.

  • A wedged IndexedDB connection fails instead of hanging forever. A worker killed with a write in flight leaves WebKit holding its IndexedDB connection, and its unfinished transaction, until the document that created the worker goes away; until then every connection to that database blocks, including connections opened afterwards in other tabs, and no event ever arrives. A read through a replacement worker therefore never returned. The adapter now keeps one deadline for the whole connection — thirty seconds of no storage event at all while work is outstanding, reset by any event, so a transaction queued behind a long one is never mistaken for a wedge — and fails every waiting call with StorageUnresponsiveError, then refuses new work rather than queueing behind it. The error says what the remedy is: reload the page, because a new worker in the same document opens the same wedged database.

  • Closing while automatic collection is in flight reports no error. close() refused the collector's next store call with "Database is closed" and handed that refusal to onBackgroundError — on the page console of every worker-hosted database that closed with a pass pending. The shutdown is now recognized as such.

Added

  • @minnowdb/core/testing exports the interaction-plan simulator: generateInteractionPlan, parseInteractionPlan, runInteractionPlan, createDatabaseDriver, InteractionFailure, and the plan and driver types. A plan is a seeded, JSON-replayable sequence of DDL, DML, transactions, concurrent rounds, faults, reopens, and maintenance across several connections, checked against a shadow model and a set of properties; the driver interface lets the same plan run over any block store in Node or across real browser tabs.
  • StorageUnresponsiveError on the main entry and @minnowdb/core/storage/contracts: a store that answered nothing for its whole deadline. It extends UnknownOutcomeError and classifies as unknown-outcome with the connection unusable. IndexedDbBlockStore.open takes unresponsiveAfterMs to change that deadline from the exported INDEXEDDB_UNRESPONSIVE_AFTER_MS default of thirty seconds.

Changed

  • The published tarball no longer carries three test-only modules that nothing exported reached (client-audit-harness, indexeddb-audit-helpers, power-loss-model).
  • The transactions guide now states exactly what ends an open SQL transaction. The idle deadline counts from the connection's last statement rather than from BEGIN, a statement still running keeps the transaction open, and no other connection's crash, reopen, recovery, compaction, or collection rolls it back. What another connection can do is win the commit race: one data commit published while a transaction has writes staged — even to a table it never touched — fails its COMMIT with WriteConflictError, publishes nothing, and leaves nothing to acknowledge, unlike the failed state an idle rollback leaves. Behaviour is unchanged; the rules were only partly written down, and two tabs made the difference look like a defect.
  • The interaction simulator models both refusals. A plan's transaction step now counts a lost commit race and acknowledges an idle rollback instead of reporting an engine defect, and the browser tab harness reports a crashed worker to its client at once, so an in-flight call fails as an unknown outcome rather than waiting out the 60-second silence deadline — a minute long enough to expire another tab's open transaction. The browser driver also recovers a crash by reloading the document, the remedy StorageUnresponsiveError names, so all three browsers run the plan's crash faults; a store that stops answering anyway is recorded in transientsAccepted and ends the run instead of failing it.

0.10.1 — September 12, 2026

Published packages: @minnowdb/core@0.10.1.

Stored formats are unchanged: OPFS layout 6, IndexedDB schema 2, block format 2, snapshot format 1, worker protocol 7. The OPFS follower protocol gains one broadcast kind, wait, which a 0.10.0 tab ignores; a mixed origin keeps working, and only the new tabs wait out a long recovery.

An audit of the 0.10.0 release, with emphasis on multi-tab coordination, leader failure, and durability, found the defects below. Every one is fixed here and pinned by a test.

Added

  • opfsDatabaseExists({ name }) on @minnowdb/core/storage/opfs: whether a database directory of that name exists, without creating one. The auto descriptor uses it to find a database an explicit opfs descriptor created; it sits beside deleteOpfsDatabase.

Fixed

  • A frozen write-lock holder no longer costs every later write the whole wait. Autocommit writes take a cross-tab admission lock, and a tab the browser paused with the lock in hand made every write in every other tab wait the full ten seconds — serially, one after the other. Once one write has waited the wait out, the writes after it take the lock only if it is free that instant and otherwise go ahead uncoordinated at once; the first ordinary grant restores coordination. Correctness never depended on the lock: every commit is still a compare-and-swap.
  • Write scopes cost fewer store round trips per statement. A scope resolves each table record once, a keyed UPDATE probes its keys once instead of twice, and a guarded upsert or keyed pre-image read over a long committed history answers by point reads of the scope's pinned snapshot instead of leasing, listing every segment and owner, and scanning the table's delta history per statement (8.5 ms per guarded upsert over 600 delta segments before).
  • Write scopes: an UPDATE onto a held UNIQUE term fails its statement. Moving a row onto a UNIQUE index term another row holds — committed, or staged earlier in the scope — was accepted and refused at commit, losing the whole scope; it now fails the statement alone, as an insert does, while a swap within one statement and a move onto a term the scope retired go through.
  • OPFS: an over-cap checkpoint is a typed refusal. The 256 MiB checkpoint bound is the one every per-resource limit shares; a write the encoded metadata can no longer fit now fails with StorageResourceLimitError (resource: "checkpoint byte") instead of a plain Error, and the storage guide says so.
  • OPFS: a leader's own writes no longer fail during a handover. A hidden leader's operations queued behind an in-flight write, when a foreground tab bid for the handles or the idle release let them go, failed with "This OPFS store connection is closed" while the store was open. They are now re-dispatched to whoever leads next, and the log proves nothing ran twice.
  • OPFS: a graceful close mid-write answers the write. Closing a leader while it served a follower's mutation shut the log down between the mutation's frame and its result frame and dropped the answer, so the follower re-sent and was told the outcome was uncertain for a write that was durable and known. The shutdown now takes its turn behind the mutation, and the channels stay open until every admitted request has its result or decline.
  • OPFS: a slow successor no longer fails every other tab. Followers looked for a leader through a fixed ten attempts, about two seconds; a successor still recovering a large log, or a leader checkpointing on its way out, left every other tab's operation failing with leader-unavailable, and a follower's mutation with OpfsUncertainOutcomeError although nothing had run. The search is now a ten-second budget that restarts on each wait answer from the connection holding the handles, so a live recovery is waited out and a frozen leader is not.
  • OPFS: power-loss artifacts are read as unwritten. A zero-filled WAL tail — the file's new length persisted, the appended frame not — refused to open the database as corruption, and a checkpoint slot whose magic landed ahead of its header was reported as a version-0 database, which stopped recovery before the intact mirror slot was consulted. Both now mean "not written": the zero tail ends the log, and a slot's version is believed only once its length and checksum verify.
  • OPFS: an over-limit checkpoint no longer wedges the database. Once the encoded state exceeded the 256 MiB checkpoint cap, every write — including the abort or rollback that would have shrunk the state — was refused forever. Operations that only retire state now go through the remaining log headroom, so the database can always be brought back under the cap.
  • OPFS: a served result recorded on a poisoned leader settled a stale ledger entry, and the next checkpoint forgot the value; the entry is now looked up inside the logged step.
  • IndexedDB: appending to a chunked journal through updateTransaction corrupted it once a closed chunk held segments — the stored tail was overwritten by a repacked one, losing blocks and duplicating segments, and every later read of the record reported the journal as corrupt. The engine reaches that path in compaction recovery. The stored tail chunk is now read, as stageTransactionArtifacts always did.
  • IndexedDB: a quota failure surfaced as AbortError. A refused write aborts the transaction and fails every queued request with AbortError first; commits now report the transaction's own error, the QuotaExceededError, as the documentation promised.
  • IndexedDB: commit cost no longer grows with the database. Every insert's commit walked the manifest's whole block set to count level-zero segments; the count now probes the block records of the table's unfolded segments only, so at two hundred prior commits a single-shot commit dropped from 537 ms to 94 ms in the fake-IndexedDB harness, and what remains scales with the segments compaction has not yet folded.
  • IndexedDB: tables with generated columns are accepted. The record validator rejected generatedValue as an unknown field, so CREATE TABLE … GENERATED ALWAYS AS (…) STORED failed on this store with a storage-corruption error.
  • Write scopes: a failing read-first statement no longer poisons the scope. An UPDATE that read the table — encoding what earlier statements had buffered — and then failed validation was classified as having failed mid-stage, and the scope could only roll back. Work the flush did is now accounted to the sets, not to the statement.
  • Write scopes: long buffered scopes keep their lease. Buffered statements stage nothing, so nothing renewed the transaction's ownership inline, and a statement loop that never yielded to the event loop starved the heartbeat timer; the scope reached its commit with an expired lease. The buffered path now renews when a third of the lease has elapsed, through the new DatabaseTransaction.renewIfDue() on the transactions entry point.
  • Write scopes: index-backed reads see the scope's own rows. A secondary-index or full-text lookup inside a scope pruned the scope's staged segments along with the committed ones the index ruled out, so a row inserted in the scope was missing from WHERE indexed = ?, IN, ranges, and COUNT, and an updated row vanished from both its old and new value once the committed table spanned more than one row group. The scope's segments are never pruned, and nothing is pruned once the scope has staged an update or delete for the table.
  • Write scopes: tx.query and tx.execute return NUMERIC values in their public form, as db.query does, instead of the engine's internal tagged strings.
  • Write scopes: unique violations fail the statement, not the scope. An insert whose key or UNIQUE index term conflicts with a row the scope holds or a committed row, a batch that repeats a UNIQUE term, and a foreign-key violation now fail that statement alone and leave the scope usable, as the transactions guide describes; commit still re-validates atomically.
  • Setting a UNIQUE-indexed column to NULL retires its term. The old value was re-registered instead, so after UPDATE … SET code = NULL no other row could take that code, and a scope that nulled a value and gave it to another row failed at commit.
  • Worker client: live subscriptions hear a lost connection. A transport error, timeout, or reopen rejected pending calls but silently dropped every live query, patch stream, observer, and buffered-writer route, so a typed live query stayed ready with stale rows for good. Each route now gets onError with the loss and then onComplete; a clean close() completes them.
  • Worker client: cancelling a read inside write() no longer fails the scope. The next scope call, or the commit itself, was refused with "Write handle already has a call in flight" while the cancelled read wound down on the worker; it now waits for it.
  • Worker client: a lost staging or transaction-record acknowledgement is recovered. write() reported OpfsUncertainOutcomeError for a stage whose artifacts were durable — a stage publishes nothing, so nothing was uncertain — and a record created without its acknowledgement stayed active until it expired. The transaction layer now reads the record back, adopts it, and continues when the error said the acknowledgement may have been lost.
  • Worker client: an unreadable frame that names no request is a connection loss. It now fails the client with DatabaseWorkerFailedError (reason: "messageerror") so onConnectionLost fires and classifyError reports the connection unusable, instead of a plain Error that neither recognised. A client reopened after close() reports page visibility again.
  • { kind: "auto" } finds an existing database. A name with no remembered choice that already held a database — created through an explicit store descriptor before the switch to auto — was opened empty on OPFS. The existing store now decides, and a reservation is only forgotten after a failed open when no database exists on that store.

0.10.0 — September 12, 2026

Published packages: @minnowdb/core@0.10.0.

Stored format changes: OPFS layout 6 and IndexedDB schema 2. The OPFS write-ahead log and checkpoint gained the served-request ledger described below; a layout-5 store is refused with StorageFormatVersionError, with no in-place upgrade — export it with a build that reads it, or delete it and re-sync. IndexedDB schema 2 moves each transaction's artifact journal into chunked records; a schema-1 database is migrated in place on open, and an older build then refuses it with StorageFormatVersionError. Block format 2 and snapshot format 1 are unchanged. The worker protocol stays at version 7: the new diagnostic frame is an event on a reserved handle that an older client ignores. The OPFS follower protocol changes shape (op carries sentAt; new kinds declined, hold, state, uncertain), so every tab of one origin must run this release together.

Added

  • Every worker failure reaches the main thread. The worker host now catches uncaught exceptions, unhandled rejections, and unreadable frames on the worker global, and every failure that belongs to no call — a failed background checkpoint, cleanup, or collection step, an election or handover that threw, a served request that could not be answered, a buffered writer flush with no onError — and posts each one to the client as a DatabaseWorkerErrorEvent. Pass onWorkerError to MinnowDatabaseClient to hear them; without it they are written to console.error. Reports are not fatal. The same seam is MinnowDatabaseOptions.onBackgroundError for a direct engine and OpfsBlockStoreOptions.onDiagnostic for a directly opened OPFS store.

  • DatabaseWorkerFailedError. The Worker object's own error and messageerror events used to fail every call with a generic message that pointed at the worker's console. They now reject with this class, carrying reason and the thrown error as cause, with a message that names the event's message, file, and line.

  • Errors keep their cause, their platform identity, and their built-in class across the worker and follower hops. A quota refusal arrives as a DOMException named QuotaExceededError; a TypeError is still a TypeError; a call pipelined behind a failed init carries the typed failure as its cause.

  • Every error answers three questions. classifyError(error) from @minnowdb/core returns { kind, mayHavePublished, retry, connectionUsable } for any error, including one rehydrated across the worker or the OPFS follower hop and the platform's own QuotaExceededError. Two marker bases make the decisive cases an instanceof: UnknownOutcomeError (the operation may have happened; reconcile before retrying) is now the base of DatabaseWorkerOutcomeUnknownError and OpfsUncertainOutcomeError, and ConnectionLostError (this connection is finished) the base of DatabaseWorkerTimeoutError and DatabaseWorkerFailedError. The Errors page holds the matrix.

  • A lost connection can be reopened. MinnowDatabaseClient accepts a transport factory (new MinnowDatabaseClient(() => new Worker(...))), reports a lost connection once through onConnectionLost, and reopen(transport?) puts a fresh worker behind the same client with the same store and options. Handles from before the loss must be recreated.

  • Why a live statement re-executes. explain() ends with a -- live: line saying whether the statement is maintained incrementally and, when not, why in plain terms. LiveQueryStats.groups reports the same per distinct statement together with its own reruns, maintained, and fallbacks counts, across the worker client.

  • Buffered writers flush when the page hides. A client.bufferedWriter() asks the worker to flush on visibilitychange to hidden and on pagehide, so rows younger than maxAgeMs are not lost to a tab switch or close.

Fixed

  • Reads after an upsert stay fast. A table whose history held an upsert segment fell off every fast read path — the streamed scan, the keyed point read, index and zone pruning — until compaction folded it, and every query replayed the whole table cell by cell in the meantime: 800 ms for a keyed lookup on a 102-column table after three upsert passes. Upsert segments now ride the streamed scan's overlay and the point-read replay like update deltas do, with the same in-place semantics as the materialized replay and the compaction fold, so a table freshly synced with upserts reads like one freshly inserted.
  • Wide tables with wide histories fit the query budget. A streamed scan over update or upsert deltas held every changed column of every delta resident, compacted each patched window over the block's whole remaining suffix for every projected column, and copied and indexed the block's entire string dictionary per column per window. A 100-column table whose rows had all been updated once could not be read at all ("Streamed window requested … bytes with 67108864 already reserved"). The overlay now keeps one reference per patched row and resolves values per window through the buffer pool, a patched window is bounded to the rows the scan asked for, and a patched string window is re-encoded into a dictionary of its own rows. Memory is bounded by the window, not by the table's width or history.
  • Wide updates commit. An autocommit updateBatch or upsertBatch whose statement staged more blocks than one storage batch holds (64 or more changed columns) failed at commit with "references block outside its transaction", because the single-shot commit did not carry the blocks the stager had flushed ahead of it. The same write inside a scope worked. Both paths now commit. On IndexedDB the same write did not fail: the store's single-shot write skipped the check its two-step stage makes, so the update committed with a segment whose blocks were never written and every read of those rows answered with the values it was meant to replace. That store now refuses such a segment, and the storage conformance suite checks the rule on every store.
  • IndexedDB journal updates scale. Updating a transaction record diffed its pending block and segment ids with nested array scans, quadratic in the journal size; the diff is now linear.
  • A lost reply no longer means a lost outcome. Every frame a follower's mutation appends now carries the request's identity, and each checkpoint carries a ledger of recently served requests and the values they returned. A follower whose leader vanished mid-request re-sends the same request, with the time it was first sent, to whoever leads next; the recovered log answers it with the value it already returned, or runs it fresh when the log proves it never ran. OpfsUncertainOutcomeError remains for three cases: a request first sent before the recovered ledger's coverage begins (ten minutes, 65,536 requests, or 8 MiB of retained values), a request whose frame was durable but whose leader died in the instant before recording its return value, and a request whose return value exceeded 64 KiB — a staging call answers with the whole transaction record, which the ledger does not retain; the engine's transaction layer recovers that case by reading the record back. A connection that becomes the leader with its own request outstanding answers itself the same way. Mutations run through the leader one at a time, from call to completion, so no frame can carry another request's identity.
  • Spurious OpfsUncertainOutcomeError from a follower tab. Three paths reported a write as uncertain when nothing was in doubt, all reproduced and now covered by tests. A write that the leader took longer than one second to run (queued behind a checkpoint, a large strict transaction, or a compaction step) timed out on the follower although it committed: the leader now posts a keepalive for every request it holds and announces a hold before a synchronous checkpoint. A write that reached a leader in the middle of a foreground handover was silently dropped and then reported uncertain although it never ran: a connection that is not leading now answers with a decline, a handover or close says goodbye to every follower first, and a declined request is sent again. A write whose leader fell silent now first pings; a leader that answers is alive and still holds the request. The error is now raised only when the leader vanished — crashed, or frozen by the browser — with the request in hand.
  • Leadership never moved to a visible tab. A leader that went hidden while another tab stayed visible kept the handles indefinitely, and a bid that arrived inside the three-second yield cooldown was dropped for good. A hidden leader now announces its state so the visible tab bids, a bid inside the cooldown is honored when it ends, the old leader stays out of elections for a grace period so the bidder can win, and a hidden leader with nothing to do for fifteen seconds while other tabs exist releases its handles before the browser can freeze it.
  • Writes that never completed. Every autocommit write took a cross-tab Web Lock with no bound, so one tab paused by the browser with the lock in hand queued every other tab's writes until each client's 60-second deadline made it permanently fatal. A write now waits ten seconds for the lock, then goes ahead without it and reports the wait; correctness never depended on the lock, only contention did.
  • An unguarded upsert retires the unique-index term it replaces. An upsertBatch on a table with a CREATE UNIQUE INDEX but no trigger and no conflictWhere skipped the read of the rows it replaced, so a value the upsert changed stayed claimed in the index and a later row could never take it. The old images are read whenever a unique secondary index exists; inside a scope they come from the write set.
  • INSERT … ON CONFLICT DO NOTHING inside a scope no longer reads the table. It asks the scope which keys exist — the key overlay plus a keyed probe of the committed snapshot — the same way a plain INSERT checks for a duplicate, so a loop of them coalesces like the rest.
  • The worker picks the store. { kind: "auto", name } opens OPFS where the worker can hold synchronous access handles and IndexedDB everywhere else, with each store's options under opfs and indexeddb. The choice is made once per name and remembered in a small IndexedDB record, so a database never silently reopens empty on the other store: a remembered store that cannot open rejects ready() with DatabaseStoreUnavailableError. client.storeKind() reports what opened, forgetStoreChoice(name) clears the memory when a database is deleted, and @minnowdb/core/worker/auto bundles the two durable stores for bundlers that cannot split worker code.
  • Reads inside a long scope no longer grow with it. A keyed SELECT … WHERE key = ? inside write() now takes the point-read path under the scope's overlay, replaying the key through the scope's staged segments instead of scanning them; the scope lists a table's committed segments once rather than on every read, checks block presence only for committed segments, and RecordCore accounts a staging call by what it appends rather than re-walking the journal. On the memory store, a keyed read with 1,000 segments staged went from 26 ms to under 1 ms, and 1,500 read-then-upsert cycles on a 60-column table from 44 ms to 7 ms per cycle.
  • The transaction journal has no length limit. An unpublished transaction used to be refused at 4,096 pending blocks or segments, because every adapter re-verified and rewrote the whole journal on each staging call. Staging now costs what it stages: the IndexedDB store keeps a transaction's journal in append-only chunks in a new transactionJournal object store (schema version 2, migrated in place on open), RecordCore — behind the OPFS and memory stores — memoizes the journal's block set and staged bytes on the journal it was built from, and the engine's transaction layer no longer rebuilds its id lists on every statement. The exported MAX_TRANSACTION_PENDING_BLOCKS, MAX_TRANSACTION_PENDING_SEGMENTS, and assertTransactionArtifactJournalLimits are gone; appendTransactionJournal joins the storage toolkit for adapters that stage. The aggregate quotas remain — 512 MiB of staged artifact bytes across active transactions, and now 1,048,576 staged blocks or segments — as the only ceiling.
  • Nobody has to chunk a write. A write() scope used to stage one segment — one block per column — per statement, so the 41st single-row INSERT on a 100-column table failed with "Transaction journal exceeds 4096 pending blocks", and the guide told readers to split large ingestions themselves. Every plain statement inside a scope now joins the table's write set, a per-key memtable: inserts, updates, deletes, and upserts of one key fold into a single net effect, and the set is encoded — at most one insert, one upsert, one delete, and one update segment per distinct column set — when the scope reads the table, takes a savepoint, commits, or has a block's worth waiting. Each statement still validates, fills defaults, reserves identity values, registers its unique keys, and proves its foreign keys immediately; an insertBatch of a key the scope already holds now fails on that statement, before it registers anything, so the scope stays usable. The lookups the engine makes for itself — which keys a guarded upsert updates, the pre-image behind a CHECK constraint — answer from the write set and the committed snapshot, and a keyed UPDATE … SET column = constant WHERE key = ? or DELETE … WHERE key = ? joins the set without reading the table, so a loop of those stages as one segment. A statement of 4,096 rows or more is encoded on the spot, after whatever the set held for its table. A staged insert, update, or delete reports segmentId: null until its segment exists. Tables with triggers keep per-statement staging. The scope's key overlay is also replayed incrementally now; a scope of many statements consulted it on each one and had turned quadratic.
  • A large write no longer looks like a dead worker. The worker reports every five seconds on each call it is still working on, and the client's requestTimeoutMs now counts silence rather than wall time, up to ten deadlines in all. A big batch write on a slow device used to hit the 60-second deadline and leave the client permanently failed.
  • Guarded upserts on wide tables. upsertBatch with conflictWhere classified every conflicting row by reading it back with SELECT *, so a 100-column table paid for decoding every column of every existing row before writing a single one, and the cost grew with each pass as history accumulated. When no insert or update trigger fires, the read now projects only the key, the guard column, and any unique secondary-index columns. A guarded upsert of 5,000 existing rows on a 102-column table went from 1.9 to 5.2 seconds down to 0.4 seconds in the engine's own measurement; narrow tables gain about 2x.
  • Elections and handovers no longer fail silently. A corruption or quota error thrown by a fire-and-forget election was an unhandled rejection inside the worker; it is now reported. The store opened for a database whose construction failed is closed instead of leaking its handles and lock.

0.9.1 — September 11, 2026

Published packages: @minnowdb/core@0.9.1.

Stored formats and the worker protocol are unchanged; no migration is needed.

Fixed

  • Deleting an OPFS database right after closing it. A connection releases its lock only once its shutdown has closed its handles, which finishes after close() returns and after a worker's dispose reply. deleteOpfsDatabase() checked the lock instantly, so a delete that followed a close by a few milliseconds was refused with OpfsDatabaseInUseError as if a connection were still open. Deletion now waits up to one second for a lock that is being let go; a connection that stays open is still refused. The retry the storage guide asked for is no longer needed.

Devtools 0.3.3 — September 11, 2026

Published packages: @minnowdb/devtools@0.3.3.

A devtools-only release: @minnowdb/core stays at 0.9.0, stored formats are unchanged, and no migration is needed.

Fixed

  • Column resizing on the Data tab. Dragging a header's trailing edge did nothing on a sortable column, which every column of a table on the Data tab is. The panel's shared drag helper refused any press inside a button, and a sortable header is a button. It now refuses only a control inside the handle itself. Query-result columns, which are not sortable, were already resizable. The handle also sits wholly inside the header, which clips its overflow, so the full 8px hit area is reachable.

0.9.0 — September 9, 2026

Published packages: @minnowdb/core@0.9.0, @minnowdb/kysely@0.4.12, @minnowdb/devtools@0.3.2, and @minnowdb/export@0.1.1.

Stored formats are unchanged; no stored-data migration is needed. Worker protocol is now 7: deploy matching client and worker builds. Patch delivery remains opt-in per subscription; ordinary full-result delivery remains supported. Close tabs running older builds before deleting an OPFS store: coordinated deletion requires Web Locks and all connections must participate in the new lifetime-lock protocol. Ordinary OPFS reads and writes retain their existing requirements.

  • OPFS leader discovery exhaustion and request queue overload now throw root-exported OpfsCoordinationError, with a structured reason and method. Its identity survives OPFS and database worker RPC. migrate() propagates it so boot code can retry without deleting data. OpfsUncertainOutcomeError remains separate and is also root-exported: reconcile a potentially committed mutation before retrying it.

  • Established live subscriptions retain their last good result through OPFS coordination failures and retry automatically, with one backoff timer per set capped at five seconds. Query, schema, corruption, and uncertain mutation errors still surface. Recovery needs no resubscription.

  • deleteOpfsDatabase() refuses with root-exported OpfsDatabaseInUseError before removing files when a participating connection is open, including an idle follower. Openers wait while deletion owns the exclusive lock. Deletion without Web Locks is refused.

  • Published core, Kysely, devtools, and export tarballs prepare their package contents before packing; the consumer smoke test validates the packed artifacts.

  • Collection retains a block while any segment record still references it. Segments and their blocks can be reclaimed together in one atomic step; separately planned cleanup pages cannot leave dangling segment references that prevent OPFS recovery. This prevents new damage; it does not repair stores already left with missing referenced blocks. Discovery visits retired segments before blocks to avoid repeating protected block pages. Automatic collection follows pending discovery cursors even when a page contains only protected artifacts. Segment discovery batches up to 64 records and their owners instead of persisting one cursor update per segment; restart and candidate bounds remain enforced. A completed discovery cycle waits for a fresh scheduling trigger instead of repeatedly scanning retained history during writes. Later-phase destinations survive every retained-manifest page; histories over 64 manifests no longer restart segment discovery and starve later cleanup phases.

  • IndexedDB collection validates retained records in 128-record batches and reuses bounded transaction-local ownership sets. Atomic corruption checks, leases, and active-write roots remain in force; retained history still affects collection latency. Table listing reads its own key range, and full snapshot pin and compaction-source checks use the same batch reader.

  • OPFS follower reads queue within existing request limits while waiting for bounded response capacity. Bursts of small metadata reads no longer fail just because three reads are executing.

  • IndexedDB observes transaction completion before submitting requests, so delayed failure processing cannot miss an already-dispatched abort.

  • The checkout recovery guide describes durable intents, stable sale IDs, payload checks, and reconciliation after a lost commit reply. Browser tests exercise recovery with foreign keys, an index, and a stock-audit trigger.

  • Scalar subqueries in INSERT VALUES and trigger INSERT bodies read the transaction snapshot and its staged writes. Autocommit retries recompute scalar subqueries after a competing commit.

  • OPFS reads cannot observe a tentative mutation after a refused WAL append. SQL idle expiry remains failed until explicit acknowledgement, and transaction operations are ordered before commit.

  • Empty snapshots stay empty across the first commit. Runnable idle snapshots renew their leases; close cancels idle scopes, closes paused cursors and exports, joins admitted storage work, and rejects late operations.

  • Worker RPCs have configurable deadlines and prompt local read cancellation. Potentially published mutations report an unknown outcome after transport loss; reconcile durable IDs before retrying. Worker close attempts termination even when graceful disposal times out.

  • Buffered flush includes earlier queued adds and pending rows behind an in-flight flush. Small multi-table writes share one bounded artifact batch; a three-table checkout uses six strict IndexedDB write transactions instead of ten. Autocommit admission coordinates across instances and, where available, tabs using Web Locks; coordinateWrites: false retains optimistic admission.

  • Packages omit internal declaration files that are unreachable from public type exports. Public types and editor documentation remain available. Devtools compacts embedded CSS whitespace when packed. The standalone live-query entry no longer loads SQL evaluators just to copy result-boundary metadata; result ownership and conversion behavior are unchanged. These packaging changes require no application changes and do not alter stored formats.

  • Live DISTINCT aggregates correctly fall back to full execution. Maintained NUMERIC ordering preserves numeric semantics, and windows that grow beyond their retained margin correctly refill after later deletions.

  • Result memo eligibility includes volatile functions in nested views. Subscriptions capture mutable parameters, Dates, and compiled plans before asynchronous registration.

  • Closing the last active subscription preserves another registration already joining its group. Closing an unused group releases its result and maintenance state even if its handle is retained.

  • Buffer pool accounting includes cache keys and fixed entry overhead. Aggregate patches stage only changed contributions, preserve accepted state on failure, and update byte totals without scanning the retained input. Ordinary patches reuse key tokens and update payload totals only for changed rows.

  • Adapters can reuse accepted maintenance from the result memo. Worker subscribePatches() transfers changed rows and retained positions after its initial reset, without retaining an extra full result in the client.

  • Window execution skips unused peer buffers and comparisons for positional functions and ROWS frames, while preserving peer preparation for ranking and exclusions. Derived output builds column vectors directly from rows. Keyed reads reduce binding allocations for short projections, read cached block vectors without redundant decoded-block lookups, and share key-search logic across append and mutation histories.

0.8.0 — September 5, 2026

Published packages: @minnowdb/core@0.8.0, @minnowdb/kysely@0.4.11, @minnowdb/react@0.2.2, and @minnowdb/devtools@0.3.1.

Stored formats and the client/worker wire protocol are unchanged. No stored-data migration is needed. Deploy matching client and worker builds. Kysely requires this core release. Use the new core with React to receive the cold-loading fix; devtools recognizes the newly supported SQL forms.

Migration: exact NUMERIC-to-integer casts round ties away from zero; floating-point casts keep ties-to-even rounding. Fractional and exponent text no longer casts directly to INTEGER. DATE ± INTERVAL now always returns a timestamp (Date), including whole-day intervals. JSONB and array comparisons use structural scalar ordering rather than encoded-string ordering. Live sets have a default 64 MiB modeled retained-byte limit; use maxRetainedBytes to configure it or bound a large subscription. Window working buffers count toward the query memory budget and fail explicitly if they do not fit; they do not spill.

Fixed

  • Typed run() reads staged transaction writes and savepoint rollbacks, returns public domain values, distinguishes Date parameters from ISO strings, and never memoizes sequence calls.
  • Decoded live queries validate retained output values after arbitrary transformations and can load through cold refresh(), including React Suspense. Mixed suppressing/raw observers all receive their intended notifications; throwing error/completion callbacks cannot stop sweeps.
  • Window SUM/AVG no longer subtract rounded cumulative totals, preserving small single-row frames after a large value. Sequence calls in unused CASE/COALESCE branches, filtered rows, and LIMIT 0 do not run. OFFSET still evaluates skipped rows, matching PostgreSQL.
  • EXTRACT preserves fractional timestamp seconds and supports TIME/INTERVAL fields. NULL-only compound members can take their type from another member.

Added and improved

  • Compatible windows share sorting and peer preparation. Prefix and whole-partition aggregates accumulate in linear time. Range aggregate trees remove repeated MIN/MAX and exclusion scans; asynchronous database window passes yield for cancellation.
  • Query and nested-block memos retain hits across unrelated-table commits, using bounded table generations proved by contiguous durable history. Missing history invalidates conservatively.
  • Live sets index retained keys, report retainedBytes, enforce retained-byte limits, and expose subscribePatches() for reset/changed-row delivery. Immutable snapshots and worker transfers still carry whole results. Additive aggregates maintain exact NUMERIC and safely summable integer contributions; unique lookup joins can patch base-table changes. Other shapes fall back to full execution.
  • Sole FULL JOINs compose with grouping, DISTINCT, wildcards, windows, and compound ON predicates. Window functions work in ORDER BY; numeric RANGE offsets require one numeric ordering term.
  • ARRAY_AGG supports ordering and DISTINCT, scalar array subscripts are one-based, and UNNEST supports typed ARRAY constructors plus WITH ORDINALITY. Array slices, multidimensional access, arbitrary/correlated UNNEST inputs, and ARRAY_AGG FILTER/window use remain unsupported.
  • Empty LIKE ESCAPE disables escaping; boolean text accepts PostgreSQL's unambiguous spellings.

0.7.10 — September 3, 2026

Published packages: @minnowdb/core@0.7.10 and @minnowdb/kysely@0.4.10.

Stored formats are unchanged and no migration is needed. The client/worker wire protocol moved from version 5 to 6: a delivered live result now carries the version it reflects and which of its rows the previous delivery already held. Deploy the client and worker from the same package build.

Added

  • Live queries over one table with a unique key are maintained incrementally. Filters, projections, ORDER BY, LIMIT, and OFFSET qualify; joins, aggregates, DISTINCT, window functions, and subqueries do not and re-execute as before. On a relevant commit the engine reads the keys the commit touched from its segments, re-evaluates exactly those keys through the executor, and patches the retained result: rows that left are removed, rows that entered or changed are merged in order, untouched rows keep their objects. A window keeps a margin of rows beyond its visible edge so a member that is deleted or edited out is replaced from rows already held; only a patch that would reach past what is held runs the statement again. Measured in Node over an in-memory store with ten windows over a 500,000-row table: one inserted row that enters every window reaches every subscriber in 2.5 ms instead of 185 ms, and one edited row inside every window in 18 ms instead of 188 ms. In Chromium with a hundred windowed subscriptions on a 100,000-row table, a row deleted from every window reaches every subscriber in 80 ms instead of 293 ms over OPFS and in 159 ms instead of 403 ms over IndexedDB, with the page's heap unchanged across the run; Firefox and WebKit complete the same run with every subscriber notified and one snapshot per change. LiveQueryStats.maintained counts patched re-runs; liveQueries({ incremental: false }) turns patching off for a set. A LiveQueryHost may implement executeMaintainable and maintain to supply it. The planner and merge add about 9 KiB raw (3 KiB gzipped) to the engine entry.
  • Typed live queries take the engine's result directly when the adapter can decode it. LiveQuerySource.decode(result) maps a delivered result to the adapter's rows; with it the query subscribes for results rather than invalidations, and execute is never called after registration. LiveQueryBackend.subscribe is the optional backend half. Over a worker, a change is one columnar transfer and no round trip. createKyselyLiveQueries({ driver, db }) uses it: given the Kysely instance, delivered rows go through the dialect's result decoding and every plugin's transformResult, so CamelCasePlugin and numeric decoding apply as they do to an executed query. Plugins added to a single builder with withPlugin are not applied to delivered results. MinnowKyselyAdapter is exported and exposes resultDecoding.
  • onChange on a low-level subscription receives a second argument, LiveQueryDelivery: the manifest version and catalog epoch the result reflects, whether it is the first delivery, and retained, which names for each row the index it had in the previous delivery. Typed queries use it to keep a row's object across a delivery that moved it, so a list keyed by row identity re-renders only the rows a commit changed even when a new row shifts every other one down. The map is only ever relative to a delivery the receiver actually took: a subscriber whose onChange threw, and a typed query whose decode failed or that skipped a delivery for a newer one, get the next result without a map rather than with one that points at rows they never held.
  • A live-query benchmark over real browser storage: packages/core/browser/live/ drives a module worker over IndexedDB or OPFS with tables of 100,000 rows and a hundred windowed subscriptions, and MINNOW_LIVE_BENCH=1 npx playwright test --config playwright.library.config.ts live-bench reports the latency from each kind of commit to the last notified subscriber.

Devtools 0.3.0 — September 3, 2026

Published packages: @minnowdb/devtools@0.3.0.

A devtools-only release: @minnowdb/core stays at 0.7.9, stored formats are unchanged, and no migration is needed. The panel keeps its API — mountMinnowDevtools, the custom element, and the handle are as they were — and grows from a table browser into a database explorer.

Added

  • Relationships. The rail lists each table's foreign keys, checks, and triggers under its columns; a foreign key clicked on the Query tab inserts the JOIN … ON it describes, and on the Data tab opens the parent table. A right-click on a cell offers the parent row a foreign key points at and the related rows in each child table, and the new Details sidebar reads the selected row in full with the same links.
  • Views. Views are listed in their own group with the query they stand for, open read-only in the grid, and no longer appear as keyless tables with an Add row button they could not honour.
  • Declared types. Columns show their SQL type — INTEGER, NUMERIC(10,2), JSONB, an enum type's name — in the rail, the grid header, and the forms, and are badged when enum, generated, or engine-filled. Enum and boolean columns are edited through a menu; generated columns are shown with their expression and never asked for.
  • Scripts and selection. Several statements separated by semicolons run in order behind one confirmation, with each outcome listed; a selection in the editor runs alone. Strings, quoted names, comments, and trigger bodies are read correctly when splitting.
  • Row cap, cancel, and statistics. An unbounded SELECT shows its first 1,000 rows and offers the rest; Cancel stops a running query; the status bar reports the engine's peak memory.
  • Live mode on both tabs, over the engine's live queries: the grid re-renders whenever the database commits something the rows can see.
  • Copy and export. Copy results as CSV or JSON, download every row of a query as CSV (read through the target's cursor when the grid holds only the capped part), copy a cell or the selected rows with Cmd/Ctrl + C, and copy a row as JSON or as an INSERT statement.
  • Affected-row counts. The confirmation for an UPDATE or DELETE with a WHERE clause counts the rows it matches and shows the number.
  • Multi-row selection with Shift and Cmd/Ctrl, deleting several rows in one confirmation; Duplicate row and Filter to this value on the cell menu; column resizing by dragging a header's edge.
  • Saved queries. The star beside a history entry keeps it at the top, past the 50-run limit and past Clear.
  • Storage tab with the database's storage statistics and maintenance status, read on demand.
  • Escape closes the floating panel; Cmd/Ctrl + K jumps to the table filter. The custom element observes its attributes, so a changed theme repaints and any other change remounts.

Fixed

  • The data grid repaints when its viewport grows or its tab is shown; it used to leave blank space below the first rows until scrolled.
  • The rail highlights the table being browsed.
  • SET, RESET, SHOW, BEGIN, and COMMIT no longer open the write confirmation.
  • Datetime filters and cursors carry the full instant — TIMESTAMP '…T10:30:00.250Z' — instead of the day alone, so a filter on a time of day means what it says and a sort on a datetime column pages by cursor.
  • A value a column cannot take — abc in a number filter — is refused in the filter editor instead of becoming = NULL and matching nothing silently.
  • Table and column names that are not bare identifiers are quoted, so a column called order date can be sorted and filtered.
  • The count query runs when the table or filters change, not on every sort.
  • The editor follows a setTheme call and an OS theme change; its base theme used to be chosen once at load.

Changed

  • The SQL compiler and the snapshot codec load on first use rather than at mount. An application that talks to the engine through the worker client now keeps them off its main thread until a statement is run or a snapshot moves — about 73 KB gzipped less at mount.
  • The unsupported-feature explanations come from a generated slice of the feature matrix, four kilobytes gzipped where the whole matrix was 29. npm run devtools:matrix regenerates it and a test fails when it is stale.
  • The devtools' DOM modules are unit-tested under happy-dom: the console, grid, explorer forms, rail, history, and custom element.

0.7.9 — September 3, 2026

Published packages: @minnowdb/core@0.7.9 and @minnowdb/react@0.2.1.

Stored formats are unchanged and no migration is needed. The client/worker wire protocol moved from version 4 to 5: an observer subscription now carries its options across the channel. Deploy the client and worker from the same package build.

Changed

  • Live queries cost what changed, not what is subscribed. A sweep looks only at the subscriptions that read a table the commits changed, found through a per-table index, instead of walking every subscription; a commit to a table nobody watches is a probe and a manifest read. Re-runs start from the probe the sweep already holds instead of reading another, and their results are compared exactly, row by row, so the digest pass every execution used to make is gone. A set runs at most eight statements at once. On IndexedDB, subscribing 500 statements takes 41% of the time it did and a commit affecting 63 of them notifies in about 60% of the time, measured in Node against fake-indexeddb.
  • Subscribing is cheap enough to do per component. A new statement no longer runs a full sweep before its first delivery; it executes once from the probe its dependencies were resolved under, reading the engine's result memo when the same statement was just run. Subscriptions opened in the same turn share their two probe reads. A subscription whose first execution raced a sweep now reconciles before delivering, so it never hands its subscriber rows the sweep already knew were stale.
  • Typed live queries ask the engine to compare before invalidating. On a relevant commit the engine re-runs the statement, compares against the rows it delivered last, and invalidates only when they differ; the re-run stays in the result memo so the adapter's execution that follows is a cache hit. A commit that leaves a query's rows as they were now costs one execution inside the worker and nothing on the main thread — previously every affected typed query re-executed over the channel, rebuilt its rows, and published a new snapshot for the version alone. The low-level form is observe(query, { suppressUnchanged: true, … }).
  • Snapshots preserve row identity. A row that did not change keeps the object it had in the previous snapshot, and rows itself is kept when nothing changed, in both LiveQuery and KeyedLiveQuery; a move record carries the retained object. Keyed diffing retains the previous key index and keys its map by the primitive value, so one snapshot indexes once: diffing a 100,000-row result with one changed row takes about a quarter of the time it did.
  • LiveQueryStats gains groupsVisited. rerunsAvoided now counts every subscribed statement a sweep did not re-run, whether or not the sweep looked at it.
  • database.liveQueries({ sharedResults: true }) hands onChange the set's retained result instead of a private copy; the worker host uses it, since it encodes the result for the channel synchronously. The default is unchanged. LiveQueryHost and LiveQueryExecuteContext are exported: a host's execute may receive the sweep's probe and whether to memoize, and its dependencyTableIds the probe the set just read.

Added

  • @minnowdb/react exports useLiveSelector(query, { select, isEqual? }), which reads one derived value from a snapshot and re-renders only when that value changes; isEqual defaults to Object.is. UseLiveSelectorOptions types it.

0.7.8 — September 3, 2026

Published packages: @minnowdb/core@0.7.8.

Stored formats are unchanged and no migration is needed. The client/worker wire protocol moved from version 3 to 4: a main-thread client and a database worker from different builds now refuse each other with "Unsupported protocol version" instead of one silently ignoring an argument the other does not know. Deploy the two from the same package build, as the workers guide already asks.

Added

  • The batch API is typed by the schema declaration. new MinnowDatabase(store, { schema }) and new MinnowDatabaseClient(worker, { store, schema }) take the schema([...]) value the application already declares, and from then on insertBatch, insert, upsertBatch, upsert, updateBatch, update, deleteBatch, delete, bufferedWriter(...).add, readTable, and every method of a write() scope are keyed by table name: a row must carry the declared required columns and may omit nullable or defaulted ones, a key has the unique column's type, an update cannot name the key or a generated column, a conflictWhere guard's value follows the column it names, and readTable returns the declared row shape — or exactly the columns it was asked for. A misspelled table or column is a compile error; a columnar batch is checked column by column. migrate() with no argument applies the declared schema. Nothing changes for a database constructed without schema: every method keeps the plain-string signature it had, and a declared database is still assignable wherever an undeclared MinnowDatabase or MinnowDatabaseClient is expected, so existing helpers keep compiling. The new types are exported from @minnowdb/core and @minnowdb/core/schema (AnySchema, TableName, TableOf, BatchInsertInput, BatchInsertRow, BatchReadRow, BatchKeyValue, BatchUpdateChanges, BatchUpdateInput, BatchDeleteInput, BatchConflictWhere, BatchUpsertOptions, BatchReadOptions, ColumnarInsertBatch). update on both the engine and the client now also accepts an explicitly undefined change and leaves that column untouched, matching InferUpdateChanges, so a patch spread from optional fields needs no filtering first.

Fixed

  • A write() scope whose guarded upsertBatch rejected every row committed cleanly in 0.7.7 by accident of ordering rather than by construction: nothing pinned it. The scope now registers its level-zero segment limit only once a segment will actually be staged, and a test covers the all-rows-skipped scope alone, beside another table's delete, and followed by an insert to the same table.
  • The five heaviest maintenance tests, and every other test that hard-coded a per-test timeout, now take a CI-aware ceiling instead of a number tuned on a developer machine; three of them timed out at 120 s under coverage on a hosted runner and blocked the 0.7.7 release until rerun.

0.7.7 — September 3, 2026

Published packages: @minnowdb/core@0.7.7.

Stored formats are unchanged and no migration is needed.

Fixed

  • A garbage-collection job that spans multiple planning pages could wedge permanently with Garbage collection segment candidate has no persisted provenance: <id> repeating forever, maintenanceStatus().consecutiveFailures climbing without bound and pendingCommitDebt never draining. Each planning page re-validated the job's entire accumulated candidate list against live storage state, not just the candidates that page added; a candidate accepted on an earlier page but legitimately reclaimed by concurrent maintenance before a later page committed (a dropped table, an aborted transaction's rebase, a rollback) failed that revalidation unconditionally, and because collectGarbageStep always resumes the same persisted job, every future tick re-threw the identical error on the same stale id. Planning now validates only the newly-submitted candidates on each page — a candidate proven once at nomination stays proven, and a candidate gone by the time collection actually runs is already handled there as a tolerated missing* outcome, not an error. A new conformance test reproduces the exact race (nominate, concurrently reclaim, submit the next page) against all three storage backends.

Added

  • WriteSession.upsertBatch (the tx.upsertBatch inside a db.write() scope) now accepts the same { conflictWhere } guard as the top-level autocommit upsertBatch/upsert, returning a StagedUpsertResult with skippedRowCount. Previously the guard existed only outside a write scope, so composing it atomically with another staged mutation — replacing an offline-created row with its server-confirmed twin only when nothing has since edited it locally, in the same commit as the delete that removes the local placeholder — required falling back to raw SQL inside the scope. The classification and row-filtering logic is now shared internally with the autocommit path, which also fixed the autocommit path doing a full batch copy and an O(n log n) sort on every guarded upsert even when nothing was actually skipped.

0.7.6 — September 3, 2026

Published packages: @minnowdb/core@0.7.6.

Stored formats are unchanged and no migration is needed.

Fixed

  • A loop of single-row UPDATEs on a folded table of tens of thousands of rows, awaited one after another with nothing in between, no longer outruns automatic compaction. Each fold of such a table rewrites every partition the updates touch, and it read and decoded its source blocks one at a time — each decode completing as a task the loop let run once per statement — while every statement added a level-zero segment, so the fold fell further behind with every statement: on a 20,000-row table latency climbed from 1.1 ms to 18 ms and the 4,100th-odd statement was refused with CompactionBacklogError; a 2,000-row table reached 4,000 segments and 60 ms per statement. Two changes close it. A fold now reads and decodes its source blocks in groups within its memory budget, each distinct block once per output block rather than once per run it feeds, so a fold of that table is 1,300 block reads instead of 2,800 and costs the loop a turn per group instead of a turn per block. And past 512 level-zero segments — twice the prefix one background fold absorbs — a write drives one fold step before its own commit rather than waiting for the 4,096-segment ceiling to refuse it, so a fold the loop has outrun stays within one fold's steps of that threshold; a step that fails or cannot help backs the table off as a background fold does. The same 6,000-statement loop now holds between about 1.4 and 3.6 ms per statement on the 20,000-row table with at most 479 level-zero segments, and 1.4 ms flat on the 2,000-row table with at most 94; maintenance-under-load pins both, and a store whose compaction checkpoints are slow pins the write-path step.

0.7.5 — September 3, 2026

Published packages: @minnowdb/core@0.7.5, @minnowdb/kysely@0.4.9, and @minnowdb/devtools@0.2.4.

Stored formats are unchanged and no migration is needed, but a query that divides two integers now returns a different value; see the migration note below.

Changed

  • Integer division follows PostgreSQL. / over two integer operands — INTEGER, BIGINT, or SMALLINT columns, integer constants, COUNT, an integer CAST, and integer arithmetic or aggregates (SUM, MIN, MAX, ABS, MOD, CASE, COALESCE) over them — truncates toward zero: 7 / 2 is 3, -7 / 2 is -3, id / 2 = 1 selects ids 2 and 3, and SUM(qty) / COUNT(*) is a whole number. A DOUBLE PRECISION or NUMERIC operand or a decimal constant (7 / 2.0) keeps the fractional quotient, and a bound parameter takes the type of its integer partner, as PostgreSQL infers it. The typing is carried through derived tables, CTEs, scalar subqueries, UPDATE … SET, RETURNING, INSERT … SELECT, and CREATE TABLE AS, and an integer quotient is itself an integer column when it lands in a new table. Division by zero is still NULL. Previously every / divided as Float64, so 7 / 2 was 3.5 — a result neither PostgreSQL nor SQLite gives — and the conformance harness had skipped the operator; it now diffs nineteen division shapes against both oracles.

Fixed

  • An untyped string constant or bound parameter written into a datetime, number, or boolean column by a SQL statement is now read in the column's type, as PostgreSQL types an unknown literal by its target: INSERT INTO t (at) VALUES ('2026-04-01T00:00:00Z'), VALUES (1, '7') into an INTEGER, SET active = 'true', ON CONFLICT DO UPDATE SET amount = '3.5', INSERT … SELECT with a literal, and a string bound to $1. Text that does not parse is still refused with the column's type error. The typed programmatic API is unchanged.
  • PostgreSQL's internal type names (int2, int4, int8, float4, float8, bool) and multi-word spellings (character varying, timestamp with time zone, timestamp without time zone, time with time zone) are accepted in CREATE TABLE and CAST, as pg_dump and migration tools emit them. TRUNCATE [TABLE] name removes every row, END commits like COMMIT, DATE(x) is the DATE cast spelled as a function, and POW is POWER.
  • More PostgreSQL spellings parse: TABLE name, FOR UPDATE / FOR SHARE (with OF, NOWAIT, SKIP LOCKED; accepted and ignored), WITH w AS [NOT] MATERIALIZED (…), CREATE TEMP | TEMPORARY | UNLOGGED TABLE, CONSTRAINT name before a column's CHECK or REFERENCES, E'…' escape strings, $tag$…$tag$ dollar-quoted strings, 5. and .5 numerics, the ~~ / !~~ / ~~* / !~~* operators, ABORT, SHOW setting, and BEGIN / SET TRANSACTION with ISOLATION LEVEL other than SERIALIZABLE (every transaction reads one snapshot, which satisfies the weaker levels).
  • SET [SESSION | LOCAL] name TO value, SET TRANSACTION …, and RESET name are accepted and ignored, as drivers and migration tools issue them on every connection; SET TIME ZONE accepts only UTC and refuses another zone rather than silently ignoring it. SUBSTRING(text FROM 'pattern') is the POSIX-regex form, and TO_HEX, QUOTE_LITERAL, and QUOTE_IDENT are new. An untyped string constant beside a number in arithmetic is read as a number ('5' + 1 is 6). transaction_timestamp(), statement_timestamp(), and clock_timestamp() read the statement clock like NOW(), and NUM_NONNULLS, NUM_NULLS, GCD, and LCM are new.
  • UPDATE … SET … FROM sources WHERE … and DELETE FROM … USING sources WHERE … join extra tables, views, or derived tables to the target, as PostgreSQL does (Kysely's updateTable().from() and deleteFrom().using(), Drizzle's update().from()). Assignments and predicates may read the sources, RETURNING works, and a target row matched by several source rows is updated or deleted once.
  • SELECT DISTINCT ON (expressions) … ORDER BY … keeps the first row of each group in ORDER BY order, as PostgreSQL does (Kysely's distinctOn(), Prisma's distinct). It lowers to a ROW_NUMBER() window over the block, so the ORDER BY may name output aliases and columns the select list omits, and LIMIT/OFFSET apply to the kept rows.
  • PostgreSQL's JSON spellings: json_agg and jsonb_agg (JSON_ARRAYAGG), json_build_object, jsonb_build_object, json_build_array, and jsonb_build_array (JSON_OBJECT and JSON_ARRAY), and to_json, to_jsonb, and row_to_json. A table alias used as a value — json_agg(agg), to_json(obj), row_to_json(u) — is the row as a JSON object, so Kysely's jsonArrayFrom and jsonObjectFrom and Drizzle's relational queries run unchanged.
  • Unquoted identifiers that match no exact name resolve to the unique case-insensitive catalog name, as PostgreSQL folds them: SELECT ID, NAME FROM USERS WHERE ID = 1, UPDATE Users SET Name = …, INSERT INTO Users (ID, Name) …. Exact names are never folded, so mixed-case tables created through the typed API keep working, and the output column of a folded bare column is the catalog spelling (id, not ID).
  • GROUP BY accepts PostgreSQL's functional dependency: with a table's whole primary key grouped, that table's other columns may appear ungrouped in the select list, HAVING, and ORDER BY — SELECT u.id, u.name, COUNT(o.id) FROM users u LEFT JOIN orders o ON … GROUP BY u.id. The "HAVING conditions must use aggregates, literals, or GROUP BY expressions" check now runs at execution, where the catalog is known, instead of at compile time.
  • ROUND(x, -n) on a plain number rounds left of the decimal point as PostgreSQL does (ROUND(1250, -2) is 1300); previously the negative count was ignored. A string constant beside an integer divides as an integer ('7' / 2 is 3), and text that is not a number in arithmetic raises the type error instead of recursing.
  • A datetime that is not a column compared against a string constant no longer fails with "Values must have comparable SQL types": NOW() > '2020-01-01', CURRENT_DATE >= '…', HAVING MAX(joined) > '2026-03-01', (SELECT MAX(joined) FROM t) > '…', and COALESCE(joined, '2000-01-01') < '…' read the constant as a timestamp, as a column comparison already did.

Added

  • @minnowdb/kysely@0.4.9 passes distinctOn(), updateTable().from(), deleteFrom().using(), the row-locking modifiers (forUpdate(), forNoKeyUpdate(), forShare(), forKeyShare(), noWait(), skipLocked(); the engine accepts and ignores them), and createTable().temporary() through to the engine instead of refusing them when the query compiles. onCommit() on a temporary table, dropTable().temporary(), and temporary views are still refused, with the alternative named.
  • @minnowdb/devtools@0.2.4 summarizes the new SET, RESET, and SHOW statements in its console and statement classifier; it has no other change.
  • ExecuteResult gains { kind: "set", action: "set" | "reset", name } for SET and RESET statements; SHOW returns a rows result. CompiledStatement gains the set and show kinds and, on update and delete, an optional from (MutationSources). Plan expression nodes gain optional integer (on /) and decimal (on constants) markers.
  • The feature matrix now lists every known unsupported PostgreSQL form as an executable unsupported row with its exact error, so the compatibility page and /agent-rules.md enumerate the gaps rather than implying them.

Migration

  • A query that relied on / returning a fraction for two integer operands must make one operand non-integer: qty / 2.0, CAST(qty AS DOUBLE PRECISION) / 2, or a NUMERIC column. Tables created through the low-level createTable API with plain number columns are unaffected; only columns declared INTEGER, BIGINT, SMALLINT, or integer: true divide as integers.

0.7.4 — September 2, 2026

Published packages: @minnowdb/core@0.7.4.

Stored formats are unchanged and no migration is needed.

Fixed

  • A loop that awaited one statement after another stopped background maintenance and was eventually refused. A finished fold lost the manifest race to the next write on every retry, or, on MemoryBlockStore, sat parked on an event-loop yield the loop never gave it, so no fold published, the table grew by one level-zero segment per commit, every write scanned all of them (1 ms rising to 18 ms per insert), the fold's owner lease expired unrenewed, the collector queued behind it, and at 4,096 commits writes failed with MaintenanceBacklogError. collectGarbage() did not help: every write at the level-zero ceiling resumed the dead owner and failed with "Transaction is no longer active". A read-then-update loop hit the same wall as CompactionBacklogError. Now (with one remaining limit, below):
    • A finished fold publishes on the write path. The next statement — a plain write or a SQL statement's scope — lands the fold's rebase and commit before its own work, and an idle database lands it on the next event-loop turn; inside that turn the fold retries against the writers already in flight without yielding, and abandons the job after 64 lost races so the next check plans a fresh one.
    • Background maintenance steps yield to the event loop or to this database's next commit, whichever comes first, so a caller that never pauses still lets folds and collection passes advance one bounded step per commit.
    • MemoryBlockStore acknowledges every commit on the next event-loop turn, as a store backed by real I/O does, so timers, the transaction heartbeat, and block compression keep running under a tight write loop.
    • A compaction job whose owner lease has expired is taken over on resume, by background maintenance and by resumeCompactionJob/compactTableStep alike: the dead owner is aborted and the job continues under a fresh transaction instead of failing every step.
    • collectGarbage() reconciles compaction jobs before it reclaims, as a background pass does, so an explicit call repairs a stalled fold rather than only collecting around it.
    • A compaction step that fails inside a background collection pass is recorded on the job and re-planned; it no longer counts as a collection failure that puts the collector on its retry timer.
    • Collection debt resets after any pass that reclaims something. A write at the debt ceiling still assists with one bounded pass, but is refused only when that pass reclaims nothing, not merely because a large backlog needs another.
    • Remaining limit: a fold of a large table reads every block it merges, and each block read waits on decompression, so a loop of single-row UPDATEs on a table of tens of thousands of rows that never yields between statements can still add level-zero segments faster than the fold retires them. Measured: 20,000 rows, one update per statement, refused at about the 4,250th statement with CompactionBacklogError. Inserts, small tables, and any loop that awaits something else between statements stay flat. A follow-up will fold level-zero segments among themselves before rewriting the base.

Changed

  • Automatic compaction backs off a failing table in time as well as in segments: the wait doubles from a quarter of a second per consecutive failure up to a minute, and the table is re-checked when it ends without waiting for another write. Before, a table at the largest level-zero prefix was re-planned on every eighth commit while the failure persisted.

0.7.3 — September 2, 2026

Published packages: @minnowdb/core@0.7.3 and @minnowdb/kysely@0.4.8.

Stored formats are unchanged and no migration is needed.

Added

  • Five SELECT spellings PostgreSQL and SQLite accept, which together caused 27-43% of the failures in every sampled random/* file of the upstream SQLLogicTest corpus: SELECT ALL; a column label without AS (SELECT amount total, SELECT + 81 col2, SELECT (a) b); a grouped column spelled bare on one side and qualified on the other (SELECT col0 FROM t GROUP BY t.col0, SELECT c.col0 + 36 FROM t AS c GROUP BY col0), with several sources resolving a bare name through the schemas at execution; a parenthesized join group (FROM ( t AS a CROSS JOIN t b )) as the first source or as a comma, CROSS JOIN, or INNER JOIN operand; and LIMIT ALL. The conformance corpus crosses each with its neighbours against SQLite and PGlite, and the feature matrix lists them.

Fixed

  • The SQLLogicTest runner rejected onlyif mysql # comment and skipif mssql # comment with "conditional must name exactly one engine", which stopped npm run test:sql:upstream on the first random/* or index/* file. Directive lines now drop a trailing # comment, and --keep-going prints the engine error under each failed record.
  • A WHERE on a derived table or CTE that paged its rows with OFFSET n, OFFSET n ROWS, LIMIT ?, LIMIT ? OFFSET ?, or FETCH FIRST ? ROWS ONLY was pushed inside the block and ran before the window, so SELECT d.id FROM (SELECT id, k FROM t ORDER BY id OFFSET 4) d WHERE d.k = 'even' returned [10] instead of [6, 8, 10]. The same block as a CTE, under a join, or under an outer ORDER BY … LIMIT was wrong the same way; only a literal LIMIT n stopped the rewrite. Every optimizer guard that asks whether a block pages its rows now shares one check that also sees offsets and placeholders, so the filter stays above such blocks.
  • An outer LIMIT n OFFSET ? over a derived table merged its limit into the derived block, leaving the offset nothing to skip past, so the statement returned no rows. The two limits now merge only when both windows are literal and neither has an offset.
  • A correlated scalar aggregate under an outer key filter now reaches the inner table's secondary index. SELECT c.id, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) FROM customers c WHERE c.id = ? grouped every order by customer on each call, about 50x the cost of the equivalent EXISTS; the outer c.id = ? (and any other outer conjunct that reads only the key columns) is now restated over the inner key, so the scan takes the index. Conjuncts that read other columns, later joins, subqueries, windows, aggregates, or statement-scoped calls stay outside, as they do for the general correlated-scalar rewrite. The performance gate gains a correlated-count-lookup workload over an indexed child table.
  • ROUND over an exact NUMERIC value returned the engine's internal tag as the row value: SELECT round(avg(total)::numeric, 2) gave " minnow-domain:numeric:75.91" where PostgreSQL gives 75.91, for a rounded column, SUM, AVG, COALESCE, and GROUP BY key alike, and a derived table or window ordering over the same expression failed with "Invalid number vector value". The rounded result carried no NUMERIC domain, so the boundary never stripped the tag. ROUND and TRUNC now infer a NUMERIC result over a NUMERIC argument, a literal digit count is that result's display scale (ROUND(m, 3) over a NUMERIC(10, 2) renders 1.250, as PostgreSQL does), and a negative digit count rounds left of the decimal point.
  • ABS, FLOOR, CEIL, MOD, and SIGN over an exact NUMERIC value threw "Arithmetic and numeric aggregates require numbers", and TRUNC silently returned a rounded double. All are now exact, numeric-in, numeric-out, as in PostgreSQL.
  • Exact +, -, *, %, and negation carry PostgreSQL's display scale: m + 1 over a NUMERIC(10, 2) renders 8.00, not 8, and -m renders -7.00. Division still selects its own scale from the values.
  • A plain-number fallback beside NUMERIC values — COALESCE(m, 0), a CASE branch, GREATEST(m, 2) — was a type clash inside a derived table and a bare 0 at the top level. It now renders as PostgreSQL's implicit cast, 0 beside 7.00, and builds the derived table.
  • UNION members of exact NUMERIC type with different precision or scale no longer fail with "UNION member column types must match"; the combined column is unconstrained NUMERIC.

Changed

  • @minnowdb/kysely types fn("round" | "abs" | "floor" | "ceil" | "mod", [numericColumn]) at the column's boundary — string, or number under resultDecoding: { numeric: "number" } — instead of refusing it, because the engine now keeps those functions exact. power and sqrt over an exact NUMERIC column remain a type error.

0.7.2 — September 2, 2026

Published packages: @minnowdb/core@0.7.2 and @minnowdb/kysely@0.4.7.

Stored formats are unchanged and no migration is needed.

Fixed

  • Correlated scalar subqueries now carry every qualified outer column used by their predicates, projection, or aggregate arguments in the distinct probe tuple. A scalar may correlate on one outer column in WHERE while using additional outer values in its result, including across multiple outer sources and when the inner set is empty.
  • Empty full-text and secondary-index commit coverage no longer becomes a persisted zero-posting delta. Such coverage still protects an active index from stale writers, but does not consume the bounded delta tail. OPFS recovery also accepts and removes these legal empty deltas from older checkpoints and WAL replays instead of rejecting the database as corrupt. Crash tests now exercise complete database commits across strict checkpoints, relaxed payload-suffix loss, and a real-browser nullable-index reopen.
  • A placeholder inside a RETURNING expression — RETURNING score + $2, or Kysely's returning((eb) => eb("points", "+", 3)) — failed with "Placeholder $2 is unbound" even though the value was passed. The statement bound its assignments and predicates but not its RETURNING items, and the buffered path then projected them from the unbound statement. Both now bind, inside and outside a transaction.
  • @minnowdb/kysely streams INSERT, UPDATE, and DELETE ... RETURNING builders. Minnow's cursor reads only SELECT, so .stream() on a mutation failed with "Expected SELECT"; the driver now runs the mutation buffered and yields its returned rows in chunks. It validates that chunkSize is a positive whole number before executing, so an invalid size cannot commit the mutation and then fail or yield the wrong rows.
  • @minnowdb/kysely introspection reports a DATE column's data type as date, not text.
  • @minnowdb/kysely keeps resultDecoding in a DB map that also carries hand-written tables (Kysely<InferKyselyDatabase<...> & Extra>). cast(..., "numeric") and the JSON functions inferred the undecoded string there.

Changed

  • @minnowdb/kysely accepts the same string-or-number operand for exact NUMERIC columns in eb.between(), eb.betweenSymmetric(), case().when(), whenMatchedAnd() / whenNotMatchedAnd(), and aggregate filterWhere() that where(), on(), and having() already took.
  • @minnowdb/kysely compiles a column's .autoIncrement() to autoincrement, the SQLite spelling Minnow reads as its auto-increment key, as Kysely's SQLite dialect does. The PostgreSQL compiler's auto_increment failed at the engine with "Expected )".
  • @minnowdb/kysely refuses more builder forms at compile time, naming the feature and an alternative, where the engine answered with a bare parse error: the T-SQL identity() column modifier (use autoIncrement(), serial, or generatedAlwaysAsIdentity()), ADD COLUMN IF NOT EXISTS, inline addIndex() in createTable(), DROP TEMPORARY TABLE, DROP MATERIALIZED VIEW, DROP VIEW / DROP INDEX ... CASCADE, dropIndex().on(table), any explicit DEFERRABLE/INITIALLY constraint modifier, table-level NULLS NOT DISTINCT and expression unique constraints, MERGE ... WHEN NOT MATCHED BY SOURCE, and multi-table updateTable([...]) / deleteFrom([...]).
  • @minnowdb/kysely refuses the JSON path operators ->$ and ->>$. They compile to a path string such as '$."name"', which Minnow's -> reads as one member name, so the query returned NULL with no error. Use -> / ->> with .key() and .at().

0.7.1 — September 2, 2026

Published packages: @minnowdb/core@0.7.1.

Stored formats are unchanged and no migration is needed.

Added

  • Per-store worker entries. @minnowdb/core/worker/indexeddb, @minnowdb/core/worker/opfs, and @minnowdb/core/worker/memory attach the same host as @minnowdb/core/worker but import one storage adapter statically. The generic entry loads adapters with a dynamic import(), which only becomes a lazy chunk under a bundler that splits worker code; Vite's default iife worker format inlines all three, so its worker carried every store (1,466 KB minified / 398 KB gzip against 1,159 KB / 319 KB for the IndexedDB store alone). A per-store worker refuses an init frame for any other store kind, and client.ready() rejects with an error naming the entry that supports it. For custom entries, attachDatabaseWorker(scope, { createStore }) accepts a WorkerStoreFactory, and singleStoreFactory(kind, open) builds a one-kind factory.

Changed

  • Correlated subqueries no longer probe the whole outer table. The distinct-probe block a correlated scalar, EXISTS, or IN subquery is rewritten into now carries the outer WHERE conjuncts that read only its own sources, and an equality-correlated scalar mirrors those key ranges onto its inner scan. A scalar subselect over 100 outer rows of a 200,000-row table went from 2.1 s to 7 ms; the correlated EXISTS form from 129 ms to 5 ms.
  • column IN (SELECT …) with no correlation is planned as a join to the subquery's distinct values instead of materializing every value first, and an outer constant on the column reaches the subquery's own scan: 168 ms to 3.5 ms on the same table. NOT IN and probes that are expressions rather than columns keep their previous plan.
  • A placeholder counts as a constant for join-key mirroring: WHERE c.id >= ? AND o.customer = c.id filters orders by the bound value, as a literal already did.
  • DATE_TRUNC(unit, column) = ? and EXTRACT(YEAR FROM column) = ? take the range rewrite when the placeholder is bound, not only when the value is a literal: 39.5 ms to 2.6 ms over 200,000 rows.
  • SUM, COUNT, and AVG over a CASE whose conditions compare one text column with literals (=, <>, IN, LIKE, ILIKE, IS NULL, and AND/OR/NOT over those) decide the branch per dictionary entry from the dictionary strings, so a high-cardinality column costs one string compare per distinct value: 22.8 ms to 3.5 ms.
  • The published package no longer carries comments in its JavaScript. The sources and the declaration files keep them; the tarball shrinks from 1.0 MB to 0.8 MB packed, and no application bundle changes.

Fixed

  • A database worker built against a different protocol version, or a failure frame without an error payload, left the client's pending call hanging forever. The call now rejects with a protocol error, and a frame that names no pending request fails the client the way a transport error does.

0.7.0 — September 1, 2026

Published packages: @minnowdb/core@0.7.0.

Added

  • Equality-correlated LATERAL queries may now group, aggregate, and take a per-row ORDER BY … LIMIT/OFFSET. GROUP BY and HAVING gain the correlation key, a global aggregate such as COUNT(*) still yields one row per outer row (0 for COUNT, NULL for the rest, as PostgreSQL returns), and a LIMIT becomes a row number partitioned by the key. LEFT JOIN LATERAL with an unselected ORDER BY term works as well.
  • RETURNING accepts any scalar expression — RETURNING id, amount * 2 AS doubled, UPPER(name) AS label — evaluated with SELECT semantics over the affected rows: the post-image for INSERT and UPDATE, the removed row for DELETE. Aggregates are refused.
  • The PostgreSQL scalar surface: CONCAT, CONCAT_WS, LEFT, RIGHT, REVERSE, REPEAT, INITCAP, SPLIT_PART, STRPOS, STARTS_WITH, TRANSLATE, ASCII, CHR, BTRIM, MD5, FORMAT, REGEXP_REPLACE, the ~, ~*, !~, !~* operators, ^, EXP, LN, LOG, LOG10, SIGN, TRUNC, PI, CBRT, DIV, WIDTH_BUCKET, the trigonometric functions, TO_CHAR, TO_DATE, TO_TIMESTAMP, MAKE_DATE, MAKE_TIMESTAMP, AGE, DATE_PART, and the remaining EXTRACT fields; :: casts; GROUP BY ordinals and output aliases; LIMIT 0; NOW(); SERIAL/BIGSERIAL/SMALLSERIAL and GENERATED … AS IDENTITY columns; ALTER TABLE … ADD COLUMN … DEFAULT backfills; UPDATE/DELETE table aliases; scalar subqueries in SET and VALUES; INSERT … SELECT from any query expression with ON CONFLICT; DISTINCT over grouped and windowed blocks; a bare ON CONFLICT DO NOTHING.
  • Untyped string literals beside a number, boolean, datetime, or DATE column read in that column's type, as PostgreSQL types an unknown-typed literal by context: WHERE placed_at > '2025-01-01' and WHERE qty = '3' compare as values. || renders numbers, booleans, and datetimes beside text.

Fixed

  • The result memo served SELECT NEXTVAL('seq') from cache, returning the same value without advancing the sequence. A scalar subquery, derived table, or CTE calling RANDOM(), GEN_RANDOM_UUID(), or a sequence was cached under the manifest version by the derived-block cache and returned the same value on every statement. SELECT (SELECT CURRENT_TIMESTAMP) failed with an internal "must be resolved before execution" error. The statement clock is now resolved once for the whole plan tree, and no cache keeps a block whose value is not a function of the data.
  • UNION … ORDER BY applied to the last member instead of the union; CURRENT_TIMESTAMP in a WHERE clause under a hidden sort reached the executor unresolved; CAST(text AS TIMESTAMP) parsed in the local zone; EXPLAIN failed on a FROM-less SELECT; ORDER BY t.col on an unqualified select was ambiguous; a duplicate key inside BEGIN … COMMIT was reported at COMMIT instead of on its statement; SELECT * mixed with expressions was refused.
  • Secondary-index lookups read every block that held a candidate. The index now hands the scan the exact rows, also through pending update and delete deltas; candidates and block locators are pooled; posting chunks are searched by binary seek. An indexed equality moved from 6.3 ms to 0.2 ms on a 200,000-row table; a point read after single-row updates from 5 ms to 0.1 ms.

Changed

  • Result rows from a wildcard select over a join (SELECT * or SELECT u.* with more than one source) now use plain column names as their keys, the names PostgreSQL returns. Previously every such key was alias.column. Only a column name that both sides of the join contribute keeps the qualified form, because one row object cannot hold two properties of the same name. The SQL itself is unchanged; only the property names on the returned rows differ. Kysely's selectAll("u") on a join now returns the keys its types declare.
  • A constant on one key of an inner join or semi-join (=, IN, a range) is mirrored onto the other key, so the other table filters before the join, through its secondary index or unique key when one applies. DATE_TRUNC('unit', col) = ts and EXTRACT(YEAR FROM col) = n plan as ranges on the column.
  • Any predicate reading one string column, SUM/COUNT/AVG over a CASE on one string column, GROUP BY over an expression of one string column, over a number or datetime column, or over several bare columns of mixed types, and comparisons and arithmetic on plain values all run on typed kernels. DATE_TRUNC and EXTRACT are epoch arithmetic. Nothing changes in their results; the planning page describes the shapes.
  • The engine with the OPFS adapter is 313 KB gzipped (was 304 KB).

Migration

No SQL needs to change. One result shape does: the property names on rows returned by a wildcard select over a join. Given

SELECT * FROM users u JOIN teams t ON t.team_id = u.team_id

0.6.9 returned { "u.user_id": 1, "u.name": "Ada", "u.team_id": 1, "t.team_id": 1, "t.team_name": "Red" }, and 0.7.0 returns { user_id: 1, name: "Ada", "u.team_id": 1, "t.team_id": 1, team_name: "Red" }. Application code that reads such a row as row["u.name"] should read row.name; team_id stays qualified on both sides because both tables have it. Queries that name their columns (SELECT u.name, t.team_name …) and single-table SELECT * already returned plain keys and are unaffected. Everything else in this release is additive; forms that previously threw now execute.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.9 — August 31, 2026

Published packages: @minnowdb/core@0.6.9.

Fixed

  • Wildcard projections are now schema-bound before select-shape planning. orders.* and quoted "orders".* can be ordered by qualified columns, expressions, or ordinals, and wildcard projections now compose with DISTINCT, window columns, set operations, CTE/derived column lists, and INSERT ... SELECT. These forms previously failed in separate late wildcard paths; some DISTINCT and window combinations reached internal result/schema alignment errors. The conformance corpus now crosses every shape-dependent lowering with wildcards and diffs the results against SQLite and PGlite.
  • CURRENT_DATE, CURRENT_TIMESTAMP, LOCALTIME, random(), gen_random_uuid(), and nextval(...) in INSERT ... VALUES are now evaluated at statement execution. Cached INSERT statements no longer freeze volatile values, and CURRENT values also work in mutation expressions and trigger bodies.
  • SQL conformance now compares output-column names and order for empty reads and empty RETURNING results, preserves the genuinely unoptimized wildcard plan, validates every corpus ordering flag, pins every feature with no external oracle, and executes all 45 SQL fences published in the guides. This audit found the runtime-value failures above and a FETCH FIRST ... WITH TIES case whose result order had previously been discarded by the test.

Changed

  • CompiledQuery may carry pendingSelectShape, pendingOutputAliases, pendingSetOrder, or preserveUnoptimizedShape on wildcard-dependent blocks. These optional fields preserve the raw shape until an execution schema supplies column names; MinnowDatabase, executeQuery, and executeRowQuery bind and optimize them automatically. Plan-inspection extensions should treat such a block as unresolved rather than assuming select.length is its final output width.

Migration

Nothing to change for correct programs. Wildcard compositions that previously threw now execute without a query rewrite. Cached mutation statements now evaluate statement-time and volatile values when they run rather than retaining a compile-time value.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.8 — August 30, 2026

Published packages: @minnowdb/core@0.6.8 and @minnowdb/kysely@0.4.6.

Fixed

  • FULL JOIN with a qualified ORDER BY reference or an ORDER BY expression no longer fails with "Unknown table alias". The full-join desugar rewrites qualified ordering references to their output aliases and routes ordering expressions through the shared hidden-column desugar, so ORDER BY d.id NULLS LAST and ORDER BY d.id IS NULL order the union the same way they order a plain join. The differential conformance harness diffs both forms against SQLite and PGlite.

Changed

  • The Kysely adapter's compiler now refuses every builder-producible form outside Minnow's profile at compile time, naming the feature and an alternative, instead of letting the engine answer with a bare parse error. Newly refused: SQL EXPLAIN (use the Minnow database's explain()), T-SQL .top(), .output(), .crossApply(), and .outerApply(), MySQL replaceInto(), onDuplicateKeyUpdate(), and ORDER BY/LIMIT on updates and deletes, SQLite's orIgnore()/orAbort()/orFail()/orRollback(), MERGE ... THEN DO NOTHING, data-modifying CTEs, CREATE/DROP SCHEMA, DROP/ALTER TYPE, materialized and temporary views, view column lists and CREATE VIEW IF NOT EXISTS, temporary tables, serial/identity/auto-increment column DDL, non-stored generated columns, NULLS NOT DISTINCT, index access methods, partial and expression indexes, and every ALTER TABLE form beyond adding and dropping columns. Statements that reached the engine before this release failed there anyway; none of these forms ever executed.

Added

  • A full-surface Kysely conformance suite executes every supported builder form against the engine with asserted rows and inferred result types: all join kinds including lateral, set operations, chained and recursive CTEs, ordering modifiers, FETCH FIRST ... WITH TIES, CASE, row tuples, window functions, aggregate DISTINCT/FILTER/ordered STRING_AGG, ON CONFLICT, multi-clause MERGE, and DDL through composite constraints, generated columns, enum types, CREATE TABLE ... AS SELECT, and sequence-backed defaults — plus a refusal test for each named compile-time rejection and type-level checks that numeric operand boundaries span joins and Kysely's $narrowType/$castTo/$notNull utilities survive Minnow's column branding.

Migration

Nothing to change for correct programs. A Kysely query that compiled before this release compiles and runs identically; the newly refused forms previously failed at the engine with a parse error and now fail earlier with the feature named. FULL JOIN queries that previously threw "Unknown table alias" on ordering now execute.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.7 — August 29, 2026

Published packages: @minnowdb/core@0.6.7.

Changed

  • The benchmark harness's request envelope moved out of the library. The @minnowdb/core/worker-protocol entry point no longer exports WorkerOperation, WorkerRequest, WorkerResponse, SuccessResponse, FailureResponse, ProgressResponse, parseRequest, success, or failure — an operation vocabulary that belonged to the docs site's benchmark worker, not to database RPC. The entry point keeps what the engine actually speaks: protocolVersion, the RPC frame types and constructors, parseRpcRequest, parseRpcResponse, and serializeError.
  • Full-text read arguments are validated identically by every block store. readFtsPostings and readFtsCandidates accept upToVersion: -1 (the base view alone) and reject anything below it or non-integral with a RangeError on all adapters; the IndexedDB store previously rejected -1 on postings reads and skipped candidate-limit validation entirely. The shared validators, validateFtsReadVersion and validateFtsCandidateLimit, are exported from the storage contract for adapter authors, and the conformance kit now asserts the refusals.

Removed

  • Dead exports with no callers and no documented behavior: rawCodec from @minnowdb/core/block-format; normalizeGarbageCollectionDiscovery, compactionRewritePlanKinds, and CompactionRewritePlanKind from the storage contract; and columnWithDefaultSpec and HasDefault from the schema DSL (both superseded — columnFromState and the builder's THasDefault parameter are the living replacements).

Migration

Nothing to change for correct programs. No engine, SQL, or query behavior changed. A program importing one of the removed symbols was exercising surface the engine itself never called; the benchmark envelope now lives with the benchmark worker, and the remaining removals have the named in-library replacements above or none needed.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration. The IndexedDB store's schema is unchanged — its indexes are now created from the same declaration they are validated against, producing the identical schema.

0.6.6 — August 29, 2026

Published packages: @minnowdb/core@0.6.6, @minnowdb/kysely@0.4.5, and @minnowdb/devtools@0.2.3.

Fixed

  • Constant-folded || no longer corrupts domain values: concatenating a folded constant with an array, JSONB, or other non-text-domain operand previously leaked the engine's internal domain encoding into the result text, or crashed on intervals. The fold, the row executor, and the vectorized executor now share one || implementation that refuses array and JSONB operands and two non-text-domain operands the way PostgreSQL's operator resolution does, with the feature named in the error.
  • @minnowdb/devtools now declares its @lezer/highlight dependency. Package managers with strict resolution previously failed at editor mount because the dependency arrived only by hoisting.

Added

  • SQL numeric constants are typed exactly, the way PostgreSQL's NUMERIC typing works: SELECT 0.1 + 0.2 is 0.3, constant arithmetic folds in exact decimal space with quotient scales following the written scales, integer literals beyond 2^53 parse instead of erroring, and scientific notation parses and — when the value stays exact — renders fully expanded, as PostgreSQL renders it. A result that reads back identically from a JavaScript number returns as an ordinary number; one the number boundary would visibly round returns as an exact decimal string. Exact constants assigned to float columns round the way PostgreSQL's assignment cast does; integer columns reject values beyond the safe range. The feature matrix gains literal.exact-decimal, literal.big-integer, and literal.scientific, conformance-tested against PGlite and SQLite.
  • A JSON column can declare its document's shape — column.json<Shape>() and column.jsonb<Shape>() — carried by the new JsonShape<Shape> branded select type. The shape is a compile-time promise only: reads and writes stay JSON text and nothing validates stored documents against it. The schema-derived Kysely database types eb.ref(column, "->"/"->>") traversal from the declared shape — member names complete, misspelled keys are compile errors, leaf types are inferred — and resultDecoding: { json: "parse" } presents the declared shape itself instead of the wide JSON union.
  • Keyed point reads now serve domain-typed projections — NUMERIC, dates, JSON, enums, and the other tagged domains — from the fast path instead of falling back to the full pipeline, about ten times faster for exact-match lookups on such columns. The performance gate gained an exact-point-lookup workload to hold the line.
  • The feature matrix records the forms the engine refuses with named errors instead of obscure failures: array subscripts, ANY/ALL over array values, || on array or JSONB operands, interval-valued arithmetic such as interval-plus-interval, and correlated JSON_TABLE documents.
  • @minnowdb/core/storage/toolkit exports PostingChunkEntry, the entry type encodePostingChunk and decodePostingChunk already used in their public signatures.

Changed

  • NUMERIC division selects its result scale the way PostgreSQL's select_div_scale does: at least sixteen significant digits, never fewer fractional digits than either operand, rounded half away from zero and capped at 1000. AVG over a column whose declared scale exceeds that selection computes real digits to the declared scale instead of padding zeros. Quotient digit strings change accordingly.
  • The worker protocol's WorkerOperation union drops "memorySample", a bench-only operation whose handler no longer exists; sending it was already an error.

Migration

Nothing to change for correct programs. Programs that compare quotient digit strings byte-for-byte will see PostgreSQL's scale selection (more digits in most divisions). SQL that previously leaked internal tags or crashed on || over arrays, JSONB, or intervals now fails with a named error. Integer literals beyond 2^53 and scientific notation now parse; extreme constant results that no JavaScript number can carry come back as exact decimal strings, a result-type change for expressions that previously errored. JSON shape declarations are optional and additive — schemas without them infer exactly as before.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.5 — August 28, 2026

Published packages: @minnowdb/core@0.6.5 and @minnowdb/kysely@0.4.4.

Fixed

  • Uncorrelated scalar and IN subqueries over logical-domain columns returned wrong results: WHERE amount = (SELECT amount ...) and WHERE amount IN (SELECT ...) on NUMERIC, UUID, JSON, and the other tagged domains matched no rows, and projecting such a subquery leaked the engine's internal domain encoding into result values. The substituted subquery literal now keeps its internal form and its column domain, so comparisons match, projected values are clean, and the result's columnDomains metadata reports the domain — which also means the Kysely driver's resultDecoding applies to scalar-subquery projections. Correlated subqueries were unaffected.
  • CAST of a NUMERIC — or any other logical-domain — column to a number or boolean target read the internal tagged encoding instead of the value's text, so CAST(amount AS DOUBLE PRECISION) threw and leaked the internal encoding into its error message. The number and boolean targets now externalize the value first, the same way the text and datetime targets always did.
  • A startTransaction() refused for an isolation level or access mode — or whose BEGIN the engine refused — permanently wedged the Kysely instance: every later statement queued behind a connection hold that was never released. The driver now frees its hold when a begin fails, the adapter keeps Kysely's own unreleasable runtime mutex out of the path, and rolling back a controlled transaction whose engine transaction already ended — the state a failed COMMIT leaves — succeeds instead of wedging.

Added

  • PostgreSQL's -> and ->> JSON operators. A text key selects an object member and an integer key selects an array element, counting from the end when negative; steps chain, share ||'s left-to-right precedence level, and work anywhere an expression does, including predicates and parameterized keys. -> returns a JSON value and ->> returns text — strings unquoted, a selected JSON null as SQL NULL. Semantics follow PostgreSQL's json type: a key of the wrong kind for the document's shape selects NULL, without jsonb's scalar-as-array reading. The operators accept stored JSON/JSONB values and, as a Minnow extension, ordinary JSON text; a document that is not JSON is an error, matching the operators' typed-json strictness rather than JSON_VALUE's quiet NULL. The feature matrix gains json.arrow, json.arrow-text, json.arrow-index, and json.arrow-untyped, conformance-tested against PGlite and SQLite.
  • The Kysely adapter compiles Kysely's JSON references — eb.ref("doc", "->>").key(…) and .at(…) — to the operators.
  • @minnowdb/kysely refuses the PostgreSQL forms Kysely's builders can produce but Minnow does not accept — DISTINCT ON, UPDATE ... FROM, DELETE ... USING, constraint-targeted ON CONFLICT, row-locking clauses, schema-qualified names, ALTER TABLE ... RENAME, and empty IN () lists — at compile time with the feature named and an alternative offered, instead of surfacing the engine's bare parse error.
  • @minnowdb/kysely types arithmetic scalar functions (round, abs, floor, ceil, mod, power, sqrt) over exact NUMERIC columns as a compile-time refusal instead of a number the engine never produces. The check keys off the SQL parameter boundary, so enabling resultDecoding: { numeric: "number" } does not reintroduce the unsound number; CAST to double precision first, or use the fn.sum/fn.avg aggregates.

Changed

  • NUMERIC/DECIMAL results from a column with a declared scale now render at exactly that scale, matching PostgreSQL: 1.5 in a NUMERIC(10, 2) column reads back as '1.50' instead of '1.5', and integers gain the scale ('7.00'). The rendering follows the declared scale through aggregates, window aggregates, COALESCE, RETURNING, and CAST(… AS NUMERIC(p, s)) results. A bare NUMERIC column, derived arithmetic results, and values cast or concatenated to text still render canonically, without trailing fractional zeros, and a non-terminating AVG quotient renders at Minnow's internal precision; all are recorded as deliberate differences in the PostgreSQL feature profile.

Migration

Nothing to change for correct programs. Statements that silently returned no rows or leaked tagged strings now return correct results; SQL that previously failed to parse on ->/->> now executes. Kysely queries using the newly refused forms already failed at execution and now fail at compile time with a clearer error, and a fn("round"/"abs"/...) call over an exact NUMERIC column no longer typechecks — it always threw at runtime — so cast or aggregate instead. Programs that compare NUMERIC result strings from declared-scale columns byte-for-byte will see the padded form ('1.50', not '1.5'); numeric comparisons, SQL predicates, and Kysely's resultDecoding: { numeric: "number" } are unaffected.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.4 — August 28, 2026

Published packages: @minnowdb/core@0.6.4.

Added

  • One statement may now hold 16,384 tokens, up from 4,096. Compilation cost is linear in tokens, so the ceiling costs nothing until a statement actually approaches it; the other compilation limits — 1,048,576 characters of text, 4,096 parameter positions, 128 syntax levels — are unchanged. Statements longer than 16,384 characters of text are still not retained in the plan cache and recompile on each execution, so very large generated statements remain better written as batches.

Fixed

  • A quoted table or alias qualifier before a wildcard — SELECT "orders".* FROM orders — now compiles. It previously failed with Expected identifier, found *, while the unquoted orders.* worked; query builders that quote every identifier, such as Kysely's selectAll("table"), hit the broken form.

Migration

Nothing to change. Both changes accept statements that previously threw SqlCompileError.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.3 — August 28, 2026

Published packages: @minnowdb/core@0.6.3 and @minnowdb/kysely@0.4.3.

Added

  • execute() now takes the engine controls a query does, as a third optional argument: execute(sql, params?, { signal?, onStats?, memoize?, executionMemoryBudgetBytes? }). A SELECT honors all of them through the query pipeline — cancellation stops between bounded batches with no partial result, and onStats reports peak modeled execution memory. Every other statement checks signal once before it starts, so an already-aborted call never mutates anything. The widening covers MinnowDatabase, MinnowDatabaseClient (the signal and stats cross the worker channel exactly as query's do), and the MinnowSqlExecutor contract — a driver written against the two-argument form remains a valid implementation.
  • @minnowdb/kysely forwards Kysely's AbortSignal to buffered queries: .execute({ signal }) now cancels the engine's work instead of only detaching from it. Streaming already forwarded the signal; buffered statements now do too.

Migration

Nothing to change. The new execute argument is optional, and third-party MinnowSqlDriver implementations with the old two-argument execute still satisfy the interface — they simply ignore the controls.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.2 — August 28, 2026

Published packages: @minnowdb/core@0.6.2 and @minnowdb/kysely@0.4.2.

Added

  • A keyed point-read fast path: a single-table WHERE that is a conjunction of column = value equalities covering the table's unique key — scalar or composite — now answers from cached decoded blocks without plan binding, streamed-view construction, or the vector pipeline. On the performance gate this took a composite-key lookup from roughly 86x slower than natively indexed SQLite to about 8x, and made scalar point lookups roughly ten times faster than before, through the Kysely dialect as well. Any statement, parameter, catalog, or physical-history condition the fast path cannot prove — a pending update or delete delta, a cross-type comparison, a view, a domain-typed column — falls back to the ordinary executor, which remains the authority on answers and errors. db.explain reports eligibility.
  • The decoded-block artifact cache now keeps recency on an intrusive doubly-linked list instead of re-inserting map keys, and reader-lease expiry checks no longer re-parse the lease's ISO timestamp on every batched block read. Both are invisible except in time.
  • The storage toolkit's record normalization and the record core now deep-copy plain record shapes directly instead of calling structuredClone per record, which was a sixth of a settle phase's CPU under sustained point updates. Values outside the plain shape still fall back to structuredClone, so copies stay exact.
  • Delete segments' key blocks are now written uncompressed: they hold only the doomed keys, compaction folds them away, and a large range delete previously spent more time in gzip than in the rest of the statement.
  • @minnowdb/kysely implements Kysely's savepoint driver hooks, so startTransaction(), savepoint(), rollbackToSavepoint(), and releaseSavepoint() work against the engine's existing SAVEPOINT support.
  • @minnowdb/kysely serializes its single logical connection through a FIFO mutex in the driver itself rather than relying on Kysely's runtime to do so. Concurrent db-level work queues behind an open transaction instead of ever joining it.
  • The SQL feature matrix now records seven runtime rejections that previously erred without a tracked entry — non-sole RIGHT JOIN and RIGHT JOIN beside SELECT *, FULL JOIN combined with grouping/DISTINCT/window functions, numeric RANGE frame offsets, DISTINCT window aggregates, window functions outside the select list, and ON DELETE SET DEFAULT — each pinned to its exact error by the conformance harness, with the agent rules and SQL guides updated to match. No engine behavior changed.

Migration

mergeInto(...).returning(...) through @minnowdb/kysely now throws at compile time instead of executing and silently returning no rows; read the affected-row count, or query the rows separately after the merge. Awaiting a db-level query inside a transaction callback waits for the transaction's connection (the same behavior as Kysely's other single-connection dialects) — use the transaction handle inside the callback. Everything else is additive or purely faster.

Storage compatibility

No stored format changed. Delete-key blocks now record the raw codec, which every reader of block format 2 already handles; block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.1 — August 28, 2026

Published packages: @minnowdb/core@0.6.1 and @minnowdb/kysely@0.4.1.

Added

  • Nested correlated subqueries now share plan-wide generated aliases and can carry an outer value through a deeper scalar or boolean block. Sibling branches can combine correlated EXISTS, IN, NOT IN, ANY, ALL, and scalar aggregates below OR, NOT, CASE, or select expressions while retaining SQL's three-valued and empty-set behavior.
  • UPDATE and DELETE subquery predicates now use the same resolution and decorrelation pipeline as SELECT; materialized subqueries automatically use the general mutation-selection executor. Target-qualified mutation RETURNING columns and target.* are accepted for PostgreSQL-builder compatibility.
  • Correlated scalar subqueries now accept JSON and composed aggregate expressions as well as ordinary single-row projections. Per-probe ORDER BY, LIMIT, and OFFSET are preserved, parameterized limits validate normally, and scalar cardinality errors remain deterministic. JSON_ARRAYAGG also supports aggregate-local ordering in row, columnar, and spilled execution.
  • @minnowdb/kysely now exports fully typed jsonBuildObject, jsonArrayFrom, and jsonObjectFrom helpers from its root and /helpers entry points. They emit Minnow-native SQL-standard JSON constructors instead of PostgreSQL's opaque json_build_object, json_agg, and to_json fragments.

Migration

No existing call changes. The deeper correlated forms, mutation predicates, ordered JSON aggregation, and Kysely JSON projection helpers are additive. Row-to-object Kysely helpers require explicit named selections; enable resultDecoding: { json: "parse" } when their runtime values should match the inferred object types.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.6.0 — August 27, 2026

Published packages: @minnowdb/core@0.6.0 and @minnowdb/kysely@0.4.0.

Added

  • Correlated scalar aggregates, IN, NOT IN, ANY, and ALL now support non-equality comparisons, LATERAL accepts range correlation, grouped select lists accept scalars determined by their grouping keys, and correlated EXISTS / NOT EXISTS work inside larger boolean expressions. The full pinned SQLLogicTest profile now runs all 10,708 selected upstream records with no capability exclusions.
  • Transaction posting changes now merge by sorted row locator instead of assuming later write batches have larger locators. A zero-row delete followed by indexed multi-row writes in one reconcile transaction therefore commits without tripping full-text/secondary-posting validation.
  • @minnowdb/kysely adds resultDecoding: { numeric: "number", json: "parse" }. It consumes aligned domain metadata for buffered, streamed, and mutation RETURNING rows, and its inferred select types follow the chosen mode. The lossless string boundary remains the default.
  • Nested JSON_OBJECT, JSON_ARRAY, JSON_ARRAYAGG, and JSON_QUERY results now embed as JSON values inside outer constructors. Ordinary text that happens to contain JSON remains escaped as a string.
  • SQL adds stored GENERATED ALWAYS AS (...) STORED columns, and the schema DSL adds .generatedSql(expression). The engine recomputes them on every write, keeps callers from assigning them, maintains indexes over them, and excludes them from inferred insert/update inputs.
  • Mutation results with RETURNING now expose returnedColumns and returnedColumnDomains, aligned with returnedRows, so adapters can apply the same logical domain handling as ordinary queries and cursor pages.
  • Ordinary query() calls now accept QueryOptions.signal. Direct, cursor, and write-session reads stop between bounded execution/storage batches, return no partial result, and release reader and spill ownership before rejecting.
  • MinnowDatabaseClient now supports signal and onStats for ordinary queries, cursor queries, and write-session reads. Controls stay on the main thread; cancellation and stats cross as structured-clone-safe protocol frames/events. The worker protocol is now version 3 so a stale worker fails early instead of silently ignoring cancellation.
  • The worker client now rehydrates every exported engine error, including DatabaseReadBacklogError and LiveQueryLimitError, with stable instanceof behavior.
  • Direct databases and worker clients now share the same single-row delete() helper. The worker host fails closed if a whitelisted implementation is missing instead of returning a successful undefined result.
  • MinnowDatabaseClient now mirrors the direct catalog and index helpers: createView, dropView, dropColumn, dropTable, createIndex, dropIndex, and buildFtsIndex.
  • Exact NUMERIC arithmetic filters now reuse a bounded, dictionary-owned comparison table across executions instead of reparsing and recomputing the same distinct values for every row.
  • Automatic maintenance now advances exact per-table layout counters from local commits, avoiding a growing segment-catalog scan before every write while still re-proving the layout after a commit from another database instance.
  • DELETE statements without RETURNING now collect their unique-key projection through the vector executor's scalar batches, avoiding a retained result object for every matched row.
  • Release checks now classify every public package subpath by audience, snapshot every exported TypeScript declaration with type-versus-runtime reachability, resolve packed tarballs in Bundler and NodeNext modes, and enforce coverage floors per published package. Frozen storage fixtures now record the exact @minnowdb/core version that wrote them.

Migration

No existing call changes. The query controls, single-row delete, worker catalog/index helpers, generated columns, result decoding, and returned-row metadata are additive. Code that previously wrapped an ordinary worker query in a cursor only to gain cancellation can pass signal directly. Deploy the client and worker from the same build, and replace any independently cached version 2 worker asset when upgrading.

An existing application-maintained column can adopt .generatedSql() only when every stored value already equals the expression; migration scans and verifies the rows. Adding a new generated column to an existing table is refused because it would require a physical rewrite. Create it with the table, or backfill an ordinary column first and adopt generation in a later migration.

Storage compatibility

No byte or native-store format changed. Generated expressions use an optional catalog field; old catalogs need no migration. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.5.0 — August 26, 2026

Published packages: @minnowdb/core@0.5.0, @minnowdb/devtools@0.2.2, @minnowdb/kysely@0.3.0, and @minnowdb/react@0.2.0.

Added

  • upsert() and upsertBatch() accept a conflictWhere guard. Inserts still land, conflicting updates only replace a row when the existing row passes the predicate, and skippedRowCount reports successful no-ops. The columnar batch path keeps its bounded memory and parameter-free execution.
  • Every query result and cursor page carries columnDomains, positionally aligned with columns. Declared columns and aliased expressions such as JSON_OBJECT / jsonBuildObject now expose the same JSON, JSONB, UUID, DATE, array, enum, and other logical-domain metadata.
  • Missing tables throw the exported UnknownTableError, including through the worker client. Its tableName is stable for recovery code; matching error messages is unnecessary.
  • The schema DSL adds column.date() for zoneless calendar dates. SQL DATE, CURRENT_DATE, and casts to DATE use canonical YYYY-MM-DD strings, while timestamps remain JavaScript Date objects. Kysely infers the same boundary.
  • Foreign keys accept { enforced: false } for catalog-only relationships. They appear in introspect() but never reject a missing parent, cascade a delete, or create an index.
  • @minnowdb/react adds cold-start Suspense, useSuspenseLiveQuery(), and staleWhileRevalidate support when switching stable query stores.

Migration

  • Code that constructs a QueryResult or cursor page itself must add a columnDomains array with one domain or null per result column.
  • Code that treated SQL DATE or CURRENT_DATE as a JavaScript Date must use the canonical calendar string. TIMESTAMP behavior did not change.
  • Replace missing-table message matching with error instanceof UnknownTableError and read error.tableName.

The other additions are opt-in. Existing foreign keys remain enforced, existing React calls keep returning loading snapshots, and unguarded upserts keep their previous behavior.

Storage compatibility

No stored format changed. Block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5 remain readable and writable without a data migration.

0.4.1 — August 26, 2026

Published package: @minnowdb/core@0.4.1.

  • Fixed multi-block snapshot restore into OPFS so renewed import batches retain a valid owner lease for the complete restore.
  • Added a packed-consumer gate that installs the actual npm tarballs outside the workspace, checks every public entry point, builds a worker application, and exercises migrations, SQL, Kysely, live queries, exports, snapshots, React, and devtools in a browser.
  • No public API or stored format changed.

0.4.0 — August 25, 2026

Published packages: @minnowdb/core@0.4.0, @minnowdb/devtools@0.2.1, @minnowdb/export@0.1.0, @minnowdb/kysely@0.2.0, and @minnowdb/react@0.1.0.

  • Established Minnow's first locked persistence contracts: block format 2, snapshot format 1, IndexedDB schema 1, and OPFS layout 5, with frozen fixtures and reopen-and-continue tests.
  • Added streamed, resumable snapshots; durable writer and snapshot leases; bounded query spill, maintenance backpressure, and explicit origin-persistence policy.
  • Added typed, keyed, and ordered-window live queries, the React external-store binding, and streaming CSV/NDJSON exports.
  • Expanded the Kysely adapter with schema-derived database types, Minnow function typing, search, cursors, and live-query wrappers.
  • Hardened IndexedDB and OPFS recovery, transaction atomicity, SQL limits, package tree shaking, and the public custom-storage contract.

This release deliberately stopped interpreting unsupported pre-contract storage prototypes as current data. Preserve experimental data with the build that can open it before moving it through a supported export path. The versioning and storage guides describe the locked formats that begin with 0.4.

0.3.0 — August 24, 2026

Published packages: @minnowdb/core@0.3.0, @minnowdb/devtools@0.2.0, and @minnowdb/kysely@0.1.0.

  • Introduced the Kysely dialect as the supported typed query-builder integration.
  • Added durable secondary indexes for selective filters and ordered reads.
  • Expanded PostgreSQL-style SQL coverage and bounded planner, metadata, and history work.
  • Retired @minnowdb/client; applications using it should move to direct SQL or @minnowdb/kysely.

0.2.1 — August 21, 2026

Published packages: @minnowdb/core@0.2.1, @minnowdb/client@0.2.0, and @minnowdb/devtools@0.1.3.

  • Made the core SQL engine the typed client's execution path instead of maintaining a parallel query implementation.
  • Hardened SQL compilation, worker behavior, and the final @minnowdb/client release.

0.2.0 — August 21, 2026

Published packages: @minnowdb/core@0.2.0, @minnowdb/client@0.1.2, and @minnowdb/devtools@0.1.2.

  • Added the OPFS block store and made storage adapters independently tree-shakeable.
  • Moved worker results over columnar frames and added typed-query result memoization.
  • Added bounded background compaction and garbage collection, partitioned folds, and explicit memory/performance soaks.
  • Folded the TypeScript console into the home-page playground over the same worker database as the SQL console.

0.1.1 — August 18, 2026

Published packages: @minnowdb/core@0.1.1, @minnowdb/client@0.1.1, and @minnowdb/devtools@0.1.1.

  • Corrected package documentation and release tagging so the installed package and repository tag describe the same build.
  • No engine behavior or stored format changed.

0.1.0 — August 18, 2026

Published packages: @minnowdb/core@0.1.0, @minnowdb/client@0.1.0, and @minnowdb/devtools@0.1.0.

Initial experimental release of the browser-local columnar SQL engine, IndexedDB storage, worker client, typed client, and embeddable devtools.

On this page

0.14.1 — October 8, 20260.14.0 — October 2, 20260.13.1 — October 2, 20260.13.0 — October 1, 20260.12.1 — October 1, 20260.12.0 — September 30, 20260.11.1 — September 27, 2026FixedTesting0.11.0 — September 17, 20260.10.4 — September 17, 2026FixedTesting0.10.3 — September 14, 2026FixedTesting0.10.2 — September 13, 2026FixedAddedChanged0.10.1 — September 12, 2026AddedFixed0.10.0 — September 12, 2026AddedFixed0.9.1 — September 11, 2026FixedDevtools 0.3.3 — September 11, 2026Fixed0.9.0 — September 9, 20260.8.0 — September 5, 2026FixedAdded and improved0.7.10 — September 3, 2026AddedDevtools 0.3.0 — September 3, 2026AddedFixedChanged0.7.9 — September 3, 2026ChangedAdded0.7.8 — September 3, 2026AddedFixed0.7.7 — September 3, 2026FixedAdded0.7.6 — September 3, 2026Fixed0.7.5 — September 3, 2026ChangedFixedAddedMigration0.7.4 — September 2, 2026FixedChanged0.7.3 — September 2, 2026AddedFixedChanged0.7.2 — September 2, 2026FixedChanged0.7.1 — September 2, 2026AddedChangedFixed0.7.0 — September 1, 2026AddedFixedChangedMigrationStorage compatibility0.6.9 — August 31, 2026FixedChangedMigrationStorage compatibility0.6.8 — August 30, 2026FixedChangedAddedMigrationStorage compatibility0.6.7 — August 29, 2026ChangedRemovedMigrationStorage compatibility0.6.6 — August 29, 2026FixedAddedChangedMigrationStorage compatibility0.6.5 — August 28, 2026FixedAddedChangedMigrationStorage compatibility0.6.4 — August 28, 2026AddedFixedMigrationStorage compatibility0.6.3 — August 28, 2026AddedMigrationStorage compatibility0.6.2 — August 28, 2026AddedMigrationStorage compatibility0.6.1 — August 28, 2026AddedMigrationStorage compatibility0.6.0 — August 27, 2026AddedMigrationStorage compatibility0.5.0 — August 26, 2026AddedMigrationStorage compatibility0.4.1 — August 26, 20260.4.0 — August 25, 20260.3.0 — August 24, 20260.2.1 — August 21, 20260.2.0 — August 21, 20260.1.1 — August 18, 20260.1.0 — August 18, 2026