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 missingthroughonBackgroundError. It now ends as cancelled, which automatic compaction treats as done;compactTableStep()raisesCompactionJobCancelledError. -
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 plainError. It now raisesCompactionJobConflictErrorwith 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 throughonBackgroundError. Collection still counts it inmaintenanceStatus()'sconsecutiveFailuresandlastError.StorageUnresponsiveErrorand 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 = 5included, 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),SUM32 ms (was 1.4 s), andSELECT *0.6 s (was 2.5 s).compactTable()withoutmemoryBudgetBytesnow 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 TABLEnow also removes each indexed column's change index; earlier versions left it behind, andcheckIntegrityreported 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, andtransactionCommitDeltaRetainedBytesare 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; theStorageResourceLimitErrorresource"log frame byte";WalWriter.appendContinuation,appendEncodedSliced, andrewind;iterateWalFramesSliced;WAL_CONTINUATION_PIECE_BYTES;EncodedRecordTooLargeErrorand a byte ceiling onencodeSyncCheckpointSliced;RecordCore.discardPreparedCommit,dumpSliced,ftsDeltaPostingCount, andloadSliced(state, pause, { owned }).RecordCore.prepareCommitalso takesftsChanges, andRecordCorekeeps the postings a commit hands it rather than copying them, so do not change them afterwards.DatabaseTransaction.setFtsChangesSliced(with{ owned }),setUniqueKeyChangesSliced(changes, { distinct }), andlargeFtsDeltaColumnsare 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.parsecall, 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 throwsNotFoundErrorthere, 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.setUniqueKeyChangesSlicedrecords 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:stallsmeasures it (add-- --store opfsfor the native store), andevent-loop-stalls.test.tskeeps it bounded. -
CREATE INDEXover 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
UNIQUEindex 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-rowUNIQUEindex 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 INDEXwas 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
UNIQUEindex 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 addsdecodeSyncCheckpointSlicedandRecordCore.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/AVGaggregates 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:
CompactionRewritePlanaddsReplayedMergeCompactionRewritePlan(kind: "merge-v2") withMergeCompactionPlannedColumnandMergeCompactionResolution;MergeCompactionPlanLayoutholds the fields both merge kinds share, andisMergeCompactionPlantells them apart.RecordCore.installValidatedFtsBaseinstalls 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 addsencodeSyncCheckpointSliced(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.
-
coordinateWritesis 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
DIVandTO_CHARavoid Float64 conversion. -
Empty-subquery
IN/NOT IN, timestamp validation and years below 100, TEXT comparisons, SQL quoting, and positionalFORMATnow 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:comparegives 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
DEFAULTfor 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 throughonBackgroundError, andcollectGarbage()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()andresumeGarbageCollectionJob()raiseGarbageCollectionJobConflictErrorin that case instead of a plainError. CallingresumeGarbageCollectionJob()with an unknown job ID still raisesGarbage collection job not found.
Testing
garbage-collection-step-race.test.tsremoves the job record just before and just after the collector's step, for bothcollectGarbage()and background collection. Each case fails without the fix.- The nightly performance gate's Linux threshold for
delta-count-starnow 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.
coordinateWritesis 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 provisionalserializeWriteScopesoption 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.writeCoordinationandclient.writeCoordination()report how far the turn reaches:cross-context,context(no Web Locks), orinstance(no store identity).- SQL
BEGINtakes the turn before its snapshot and keeps it throughCOMMIT,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 INDEXmarks 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 SEQUENCEworks 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
1349451771permanently 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
DELETEfollowed byCREATE INDEXresurrected the deleted row on every indexed range query, and anUPDATEfollowed byCREATE INDEXserved 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 ALLmember returned rows a laterUPDATEhad 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-v1postings exist only for rows whose every indexed value is non-null, so an index on(a, b)named no row withbNULL. A lookup onaalone that pruned through it dropped those rows: aSELECTmissed them and anUPDATEreported one row too few, with no error and anEXPLAINthat said the index was pruning. The newtuple-v2encoding 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 totuple-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.UNIQUEsemantics 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 INDEXthenCREATE INDEXrebuilds 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 encodestuple-v1terms into atuple-v2index 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
DELETEand anINSERTon 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
INSERTwas 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, andDELETEon 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 INDEXno 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 surfacedTableRecordConflictError; 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, whichcollectGarbage()surfaced to the caller while another tab's background pass was running. It now raisesGarbageCollectionJobConflictErrorlike 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 throughonBackgroundError. 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 toonBackgroundError— 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/testingexports 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.StorageUnresponsiveErroron the main entry and@minnowdb/core/storage/contracts: a store that answered nothing for its whole deadline. It extendsUnknownOutcomeErrorand classifies asunknown-outcomewith the connection unusable.IndexedDbBlockStore.opentakesunresponsiveAfterMsto change that deadline from the exportedINDEXEDDB_UNRESPONSIVE_AFTER_MSdefault 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 itsCOMMITwithWriteConflictError, 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
StorageUnresponsiveErrornames, so all three browsers run the plan's crash faults; a store that stops answering anyway is recorded intransientsAcceptedand 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. Theautodescriptor uses it to find a database an explicitopfsdescriptor created; it sits besidedeleteOpfsDatabase.
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
UPDATEprobes 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 plainError, 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 withOpfsUncertainOutcomeErroralthough nothing had run. The search is now a ten-second budget that restarts on eachwaitanswer 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
updateTransactioncorrupted 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, asstageTransactionArtifactsalways did. - IndexedDB: a quota failure surfaced as
AbortError. A refused write aborts the transaction and fails every queued request withAbortErrorfirst; commits now report the transaction's own error, theQuotaExceededError, 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
generatedValueas an unknown field, soCREATE TABLE … GENERATED ALWAYS AS (…) STOREDfailed on this store with a storage-corruption error. - Write scopes: a failing read-first statement no longer poisons the scope. An
UPDATEthat 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, andCOUNT, 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.queryandtx.executereturn NUMERIC values in their public form, asdb.querydoes, 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 = NULLno 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
readywith stale rows for good. Each route now getsonErrorwith the loss and thenonComplete; a cleanclose()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()reportedOpfsUncertainOutcomeErrorfor 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") soonConnectionLostfires andclassifyErrorreports the connection unusable, instead of a plainErrorthat neither recognised. A client reopened afterclose()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 toauto— 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 aDatabaseWorkerErrorEvent. PassonWorkerErrortoMinnowDatabaseClientto hear them; without it they are written toconsole.error. Reports are not fatal. The same seam isMinnowDatabaseOptions.onBackgroundErrorfor a direct engine andOpfsBlockStoreOptions.onDiagnosticfor a directly opened OPFS store. -
DatabaseWorkerFailedError. The Worker object's ownerrorandmessageerrorevents used to fail every call with a generic message that pointed at the worker's console. They now reject with this class, carryingreasonand the thrown error ascause, 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 aDOMExceptionnamedQuotaExceededError; aTypeErroris still aTypeError; a call pipelined behind a failed init carries the typed failure as itscause. -
Every error answers three questions.
classifyError(error)from@minnowdb/corereturns{ kind, mayHavePublished, retry, connectionUsable }for any error, including one rehydrated across the worker or the OPFS follower hop and the platform's ownQuotaExceededError. Two marker bases make the decisive cases aninstanceof:UnknownOutcomeError(the operation may have happened; reconcile before retrying) is now the base ofDatabaseWorkerOutcomeUnknownErrorandOpfsUncertainOutcomeError, andConnectionLostError(this connection is finished) the base ofDatabaseWorkerTimeoutErrorandDatabaseWorkerFailedError. The Errors page holds the matrix. -
A lost connection can be reopened.
MinnowDatabaseClientaccepts a transport factory (new MinnowDatabaseClient(() => new Worker(...))), reports a lost connection once throughonConnectionLost, andreopen(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.groupsreports the same per distinct statement together with its ownreruns,maintained, andfallbackscounts, across the worker client. -
Buffered writers flush when the page hides. A
client.bufferedWriter()asks the worker to flush onvisibilitychangeto hidden and onpagehide, so rows younger thanmaxAgeMsare 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
updateBatchorupsertBatchwhose 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.
OpfsUncertainOutcomeErrorremains 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
OpfsUncertainOutcomeErrorfrom 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
upsertBatchon a table with aCREATE UNIQUE INDEXbut no trigger and noconflictWhereskipped 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 NOTHINGinside 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 underopfsandindexeddb. 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 rejectsready()withDatabaseStoreUnavailableError.client.storeKind()reports what opened,forgetStoreChoice(name)clears the memory when a database is deleted, and@minnowdb/core/worker/autobundles 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 = ?insidewrite()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
transactionJournalobject 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 exportedMAX_TRANSACTION_PENDING_BLOCKS,MAX_TRANSACTION_PENDING_SEGMENTS, andassertTransactionArtifactJournalLimitsare gone;appendTransactionJournaljoins 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; aninsertBatchof 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 aCHECKconstraint — answer from the write set and the committed snapshot, and a keyedUPDATE … SET column = constant WHERE key = ?orDELETE … 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 reportssegmentId: nulluntil 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
requestTimeoutMsnow 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.
upsertBatchwithconflictWhereclassified every conflicting row by reading it back withSELECT *, 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 withOpfsDatabaseInUseErroras 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 structuredreasonandmethod. Its identity survives OPFS and database worker RPC.migrate()propagates it so boot code can retry without deleting data.OpfsUncertainOutcomeErrorremains 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-exportedOpfsDatabaseInUseErrorbefore 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: falseretains 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
ROWSframes, 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 exposesubscribePatches()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, andOFFSETqualify; 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.maintainedcounts patched re-runs;liveQueries({ incremental: false })turns patching off for a set. ALiveQueryHostmay implementexecuteMaintainableandmaintainto 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, andexecuteis never called after registration.LiveQueryBackend.subscribeis 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'stransformResult, soCamelCasePluginand numeric decoding apply as they do to an executed query. Plugins added to a single builder withwithPluginare not applied to delivered results.MinnowKyselyAdapteris exported and exposesresultDecoding. onChangeon a low-level subscription receives a second argument,LiveQueryDelivery: the manifest version and catalog epoch the result reflects, whether it is the first delivery, andretained, 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 whoseonChangethrew, 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, andMINNOW_LIVE_BENCH=1 npx playwright test --config playwright.library.config.ts live-benchreports 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 … ONit 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
SELECTshows 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 anINSERTstatement. - Affected-row counts. The confirmation for an
UPDATEorDELETEwith aWHEREclause counts the rows it matches and shows the number. - Multi-row selection with
ShiftandCmd/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.
Escapecloses the floating panel;Cmd/Ctrl + Kjumps to the table filter. The custom element observes its attributes, so a changedthemerepaints 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, andCOMMITno 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 —
abcin a number filter — is refused in the filter editor instead of becoming= NULLand matching nothing silently. - Table and column names that are not bare identifiers are quoted, so a column called
order datecan be sorted and filtered. - The count query runs when the table or filters change, not on every sort.
- The editor follows a
setThemecall 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:matrixregenerates 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
rowsitself is kept when nothing changed, in bothLiveQueryandKeyedLiveQuery; amoverecord 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. LiveQueryStatsgainsgroupsVisited.rerunsAvoidednow counts every subscribed statement a sweep did not re-run, whether or not the sweep looked at it.database.liveQueries({ sharedResults: true })handsonChangethe 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.LiveQueryHostandLiveQueryExecuteContextare exported: a host'sexecutemay receive the sweep's probe and whether to memoize, and itsdependencyTableIdsthe probe the set just read.
Added
@minnowdb/reactexportsuseLiveSelector(query, { select, isEqual? }), which reads one derived value from a snapshot and re-renders only when that value changes;isEqualdefaults toObject.is.UseLiveSelectorOptionstypes 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 })andnew MinnowDatabaseClient(worker, { store, schema })take theschema([...])value the application already declares, and from then oninsertBatch,insert,upsertBatch,upsert,updateBatch,update,deleteBatch,delete,bufferedWriter(...).add,readTable, and every method of awrite()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, aconflictWhereguard'svaluefollows the column it names, andreadTablereturns 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 withoutschema: every method keeps the plain-string signature it had, and a declared database is still assignable wherever an undeclaredMinnowDatabaseorMinnowDatabaseClientis expected, so existing helpers keep compiling. The new types are exported from@minnowdb/coreand@minnowdb/core/schema(AnySchema,TableName,TableOf,BatchInsertInput,BatchInsertRow,BatchReadRow,BatchKeyValue,BatchUpdateChanges,BatchUpdateInput,BatchDeleteInput,BatchConflictWhere,BatchUpsertOptions,BatchReadOptions,ColumnarInsertBatch).updateon both the engine and the client now also accepts an explicitlyundefinedchange and leaves that column untouched, matchingInferUpdateChanges, so a patch spread from optional fields needs no filtering first.
Fixed
- A
write()scope whose guardedupsertBatchrejected 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().consecutiveFailuresclimbing without bound andpendingCommitDebtnever 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 becausecollectGarbageStepalways 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 toleratedmissing*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(thetx.upsertBatchinside adb.write()scope) now accepts the same{ conflictWhere }guard as the top-level autocommitupsertBatch/upsert, returning aStagedUpsertResultwithskippedRowCount. 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 withCompactionBacklogError; 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-loadpins 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, orSMALLINTcolumns, integer constants,COUNT, an integerCAST, and integer arithmetic or aggregates (SUM,MIN,MAX,ABS,MOD,CASE,COALESCE) over them — truncates toward zero:7 / 2is3,-7 / 2is-3,id / 2 = 1selects ids 2 and 3, andSUM(qty) / COUNT(*)is a whole number. ADOUBLE PRECISIONorNUMERICoperand 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, andCREATE TABLE AS, and an integer quotient is itself an integer column when it lands in a new table. Division by zero is stillNULL. Previously every/divided as Float64, so7 / 2was3.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 anINTEGER,SET active = 'true',ON CONFLICT DO UPDATE SET amount = '3.5',INSERT … SELECTwith 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 inCREATE TABLEandCAST, as pg_dump and migration tools emit them.TRUNCATE [TABLE] nameremoves every row,ENDcommits likeCOMMIT,DATE(x)is theDATEcast spelled as a function, andPOWisPOWER. - More PostgreSQL spellings parse:
TABLE name,FOR UPDATE/FOR SHARE(withOF,NOWAIT,SKIP LOCKED; accepted and ignored),WITH w AS [NOT] MATERIALIZED (…),CREATE TEMP | TEMPORARY | UNLOGGED TABLE,CONSTRAINT namebefore a column'sCHECKorREFERENCES,E'…'escape strings,$tag$…$tag$dollar-quoted strings,5.and.5numerics, the~~/!~~/~~*/!~~*operators,ABORT,SHOW setting, andBEGIN/SET TRANSACTIONwithISOLATION LEVELother thanSERIALIZABLE(every transaction reads one snapshot, which satisfies the weaker levels). SET [SESSION | LOCAL] name TO value,SET TRANSACTION …, andRESET nameare accepted and ignored, as drivers and migration tools issue them on every connection;SET TIME ZONEaccepts only UTC and refuses another zone rather than silently ignoring it.SUBSTRING(text FROM 'pattern')is the POSIX-regex form, andTO_HEX,QUOTE_LITERAL, andQUOTE_IDENTare new. An untyped string constant beside a number in arithmetic is read as a number ('5' + 1is 6).transaction_timestamp(),statement_timestamp(), andclock_timestamp()read the statement clock likeNOW(), andNUM_NONNULLS,NUM_NULLS,GCD, andLCMare new.UPDATE … SET … FROM sources WHERE …andDELETE FROM … USING sources WHERE …join extra tables, views, or derived tables to the target, as PostgreSQL does (Kysely'supdateTable().from()anddeleteFrom().using(), Drizzle'supdate().from()). Assignments and predicates may read the sources,RETURNINGworks, 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'sdistinctOn(), Prisma'sdistinct). It lowers to aROW_NUMBER()window over the block, so theORDER BYmay name output aliases and columns the select list omits, andLIMIT/OFFSETapply to the kept rows.- PostgreSQL's JSON spellings:
json_aggandjsonb_agg(JSON_ARRAYAGG),json_build_object,jsonb_build_object,json_build_array, andjsonb_build_array(JSON_OBJECT and JSON_ARRAY), andto_json,to_jsonb, androw_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'sjsonArrayFromandjsonObjectFromand 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, notID). GROUP BYaccepts 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, andORDER 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' / 2is 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) > '…', andCOALESCE(joined, '2000-01-01') < '…'read the constant as a timestamp, as a column comparison already did.
Added
@minnowdb/kysely@0.4.9passesdistinctOn(),updateTable().from(),deleteFrom().using(), the row-locking modifiers (forUpdate(),forNoKeyUpdate(),forShare(),forKeyShare(),noWait(),skipLocked(); the engine accepts and ignores them), andcreateTable().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.4summarizes the newSET,RESET, andSHOWstatements in its console and statement classifier; it has no other change.ExecuteResultgains{ kind: "set", action: "set" | "reset", name }forSETandRESETstatements;SHOWreturns arowsresult.CompiledStatementgains thesetandshowkinds and, onupdateanddelete, an optionalfrom(MutationSources). Plan expression nodes gain optionalinteger(on/) anddecimal(on constants) markers.- The feature matrix now lists every known unsupported PostgreSQL form as an executable
unsupportedrow with its exact error, so the compatibility page and/agent-rules.mdenumerate 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 aNUMERICcolumn. Tables created through the low-levelcreateTableAPI with plainnumbercolumns are unaffected; only columns declaredINTEGER,BIGINT,SMALLINT, orinteger: truedivide 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 withMaintenanceBacklogError.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 asCompactionBacklogError. 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.
MemoryBlockStoreacknowledges 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/compactTableStepalike: 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 withCompactionBacklogError. 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 withoutAS(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, orINNER JOINoperand; andLIMIT 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 # commentandskipif mssql # commentwith "conditional must name exactly one engine", which stoppednpm run test:sql:upstreamon the firstrandom/*orindex/*file. Directive lines now drop a trailing# comment, and--keep-goingprints the engine error under each failed record. - A
WHEREon a derived table or CTE that paged its rows withOFFSET n,OFFSET n ROWS,LIMIT ?,LIMIT ? OFFSET ?, orFETCH FIRST ? ROWS ONLYwas pushed inside the block and ran before the window, soSELECT 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 outerORDER BY … LIMITwas wrong the same way; only a literalLIMIT nstopped 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 equivalentEXISTS; the outerc.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 acorrelated-count-lookupworkload over an indexed child table. ROUNDover an exactNUMERICvalue returned the engine's internal tag as the row value:SELECT round(avg(total)::numeric, 2)gave" minnow-domain:numeric:75.91"where PostgreSQL gives75.91, for a rounded column,SUM,AVG,COALESCE, andGROUP BYkey alike, and a derived table or window ordering over the same expression failed with "Invalid number vector value". The rounded result carried noNUMERICdomain, so the boundary never stripped the tag.ROUNDandTRUNCnow infer aNUMERICresult over aNUMERICargument, a literal digit count is that result's display scale (ROUND(m, 3)over aNUMERIC(10, 2)renders1.250, as PostgreSQL does), and a negative digit count rounds left of the decimal point.ABS,FLOOR,CEIL,MOD, andSIGNover an exactNUMERICvalue threw "Arithmetic and numeric aggregates require numbers", andTRUNCsilently returned a rounded double. All are now exact, numeric-in, numeric-out, as in PostgreSQL.- Exact
+,-,*,%, and negation carry PostgreSQL's display scale:m + 1over aNUMERIC(10, 2)renders8.00, not8, and-mrenders-7.00. Division still selects its own scale from the values. - A plain-number fallback beside
NUMERICvalues —COALESCE(m, 0), aCASEbranch,GREATEST(m, 2)— was a type clash inside a derived table and a bare0at the top level. It now renders as PostgreSQL's implicit cast,0beside7.00, and builds the derived table. UNIONmembers of exactNUMERICtype with different precision or scale no longer fail with "UNION member column types must match"; the combined column is unconstrainedNUMERIC.
Changed
@minnowdb/kyselytypesfn("round" | "abs" | "floor" | "ceil" | "mod", [numericColumn])at the column's boundary —string, ornumberunderresultDecoding: { numeric: "number" }— instead of refusing it, because the engine now keeps those functions exact.powerandsqrtover an exactNUMERICcolumn 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
WHEREwhile 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
RETURNINGexpression —RETURNING score + $2, or Kysely'sreturning((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 itsRETURNINGitems, and the buffered path then projected them from the unbound statement. Both now bind, inside and outside a transaction. @minnowdb/kyselystreamsINSERT,UPDATE, andDELETE ... RETURNINGbuilders. Minnow's cursor reads onlySELECT, 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 thatchunkSizeis a positive whole number before executing, so an invalid size cannot commit the mutation and then fail or yield the wrong rows.@minnowdb/kyselyintrospection reports aDATEcolumn's data type asdate, nottext.@minnowdb/kyselykeepsresultDecodingin 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/kyselyaccepts the same string-or-number operand for exactNUMERICcolumns ineb.between(),eb.betweenSymmetric(),case().when(),whenMatchedAnd()/whenNotMatchedAnd(), and aggregatefilterWhere()thatwhere(),on(), andhaving()already took.@minnowdb/kyselycompiles a column's.autoIncrement()toautoincrement, the SQLite spelling Minnow reads as its auto-increment key, as Kysely's SQLite dialect does. The PostgreSQL compiler'sauto_incrementfailed at the engine with "Expected )".@minnowdb/kyselyrefuses more builder forms at compile time, naming the feature and an alternative, where the engine answered with a bare parse error: the T-SQLidentity()column modifier (useautoIncrement(),serial, orgeneratedAlwaysAsIdentity()),ADD COLUMN IF NOT EXISTS, inlineaddIndex()increateTable(),DROP TEMPORARY TABLE,DROP MATERIALIZED VIEW,DROP VIEW/DROP INDEX ... CASCADE,dropIndex().on(table), any explicitDEFERRABLE/INITIALLYconstraint modifier, table-levelNULLS NOT DISTINCTand expression unique constraints,MERGE ... WHEN NOT MATCHED BY SOURCE, and multi-tableupdateTable([...])/deleteFrom([...]).@minnowdb/kyselyrefuses 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 returnedNULLwith 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/memoryattach the same host as@minnowdb/core/workerbut import one storage adapter statically. The generic entry loads adapters with a dynamicimport(), which only becomes a lazy chunk under a bundler that splits worker code; Vite's defaultiifeworker 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, andclient.ready()rejects with an error naming the entry that supports it. For custom entries,attachDatabaseWorker(scope, { createStore })accepts aWorkerStoreFactory, andsingleStoreFactory(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, orINsubquery is rewritten into now carries the outerWHEREconjuncts 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 correlatedEXISTSform 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 INand 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.idfiltersordersby the bound value, as a literal already did. DATE_TRUNC(unit, column) = ?andEXTRACT(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, andAVGover aCASEwhose conditions compare one text column with literals (=,<>,IN,LIKE,ILIKE,IS NULL, andAND/OR/NOTover 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
LATERALqueries may now group, aggregate, and take a per-rowORDER BY … LIMIT/OFFSET.GROUP BYandHAVINGgain the correlation key, a global aggregate such asCOUNT(*)still yields one row per outer row (0 forCOUNT,NULLfor the rest, as PostgreSQL returns), and aLIMITbecomes a row number partitioned by the key.LEFT JOIN LATERALwith an unselectedORDER BYterm works as well. RETURNINGaccepts any scalar expression —RETURNING id, amount * 2 AS doubled, UPPER(name) AS label— evaluated withSELECTsemantics over the affected rows: the post-image forINSERTandUPDATE, the removed row forDELETE. 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 remainingEXTRACTfields;::casts;GROUP BYordinals and output aliases;LIMIT 0;NOW();SERIAL/BIGSERIAL/SMALLSERIALandGENERATED … AS IDENTITYcolumns;ALTER TABLE … ADD COLUMN … DEFAULTbackfills;UPDATE/DELETEtable aliases; scalar subqueries inSETandVALUES;INSERT … SELECTfrom any query expression withON CONFLICT;DISTINCTover grouped and windowed blocks; a bareON CONFLICT DO NOTHING. - Untyped string literals beside a number, boolean, datetime, or
DATEcolumn read in that column's type, as PostgreSQL types an unknown-typed literal by context:WHERE placed_at > '2025-01-01'andWHERE 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 callingRANDOM(),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 BYapplied to the last member instead of the union;CURRENT_TIMESTAMPin aWHEREclause under a hidden sort reached the executor unresolved;CAST(text AS TIMESTAMP)parsed in the local zone;EXPLAINfailed on aFROM-lessSELECT;ORDER BY t.colon an unqualified select was ambiguous; a duplicate key insideBEGIN … COMMITwas reported atCOMMITinstead 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 *orSELECT u.*with more than one source) now use plain column names as their keys, the names PostgreSQL returns. Previously every such key wasalias.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'sselectAll("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) = tsandEXTRACT(YEAR FROM col) = nplan as ranges on the column. - Any predicate reading one string column,
SUM/COUNT/AVGover aCASEon one string column,GROUP BYover 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_TRUNCandEXTRACTare 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_id0.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 withDISTINCT, window columns, set operations, CTE/derived column lists, andINSERT ... SELECT. These forms previously failed in separate late wildcard paths; someDISTINCTand 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(), andnextval(...)inINSERT ... VALUESare 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
RETURNINGresults, 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 aFETCH FIRST ... WITH TIEScase whose result order had previously been discarded by the test.
Changed
CompiledQuerymay carrypendingSelectShape,pendingOutputAliases,pendingSetOrder, orpreserveUnoptimizedShapeon wildcard-dependent blocks. These optional fields preserve the raw shape until an execution schema supplies column names;MinnowDatabase,executeQuery, andexecuteRowQuerybind and optimize them automatically. Plan-inspection extensions should treat such a block as unresolved rather than assumingselect.lengthis 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 JOINwith a qualifiedORDER BYreference or anORDER BYexpression 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, soORDER BY d.id NULLS LASTandORDER BY d.id IS NULLorder 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'sexplain()), T-SQL.top(),.output(),.crossApply(), and.outerApply(), MySQLreplaceInto(),onDuplicateKeyUpdate(), andORDER BY/LIMITon updates and deletes, SQLite'sorIgnore()/orAbort()/orFail()/orRollback(),MERGE ... THEN DO NOTHING, data-modifying CTEs,CREATE/DROP SCHEMA,DROP/ALTER TYPE, materialized and temporary views, view column lists andCREATE 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 everyALTER TABLEform 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, aggregateDISTINCT/FILTER/orderedSTRING_AGG,ON CONFLICT, multi-clauseMERGE, 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/$notNullutilities 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-protocolentry point no longer exportsWorkerOperation,WorkerRequest,WorkerResponse,SuccessResponse,FailureResponse,ProgressResponse,parseRequest,success, orfailure— 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, andserializeError. - Full-text read arguments are validated identically by every block store.
readFtsPostingsandreadFtsCandidatesacceptupToVersion: -1(the base view alone) and reject anything below it or non-integral with aRangeErroron all adapters; the IndexedDB store previously rejected-1on postings reads and skipped candidate-limit validation entirely. The shared validators,validateFtsReadVersionandvalidateFtsCandidateLimit, 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:
rawCodecfrom@minnowdb/core/block-format;normalizeGarbageCollectionDiscovery,compactionRewritePlanKinds, andCompactionRewritePlanKindfrom the storage contract; andcolumnWithDefaultSpecandHasDefaultfrom the schema DSL (both superseded —columnFromStateand the builder'sTHasDefaultparameter 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/devtoolsnow declares its@lezer/highlightdependency. 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
NUMERICtyping works:SELECT 0.1 + 0.2is0.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 gainsliteral.exact-decimal,literal.big-integer, andliteral.scientific, conformance-tested against PGlite and SQLite. - A JSON column can declare its document's shape —
column.json<Shape>()andcolumn.jsonb<Shape>()— carried by the newJsonShape<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 typeseb.ref(column, "->"/"->>")traversal from the declared shape — member names complete, misspelled keys are compile errors, leaf types are inferred — andresultDecoding: { 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/ALLover array values,||on array or JSONB operands, interval-valued arithmetic such as interval-plus-interval, and correlatedJSON_TABLEdocuments. @minnowdb/core/storage/toolkitexportsPostingChunkEntry, the entry typeencodePostingChunkanddecodePostingChunkalready used in their public signatures.
Changed
NUMERICdivision selects its result scale the way PostgreSQL'sselect_div_scaledoes: at least sixteen significant digits, never fewer fractional digits than either operand, rounded half away from zero and capped at 1000.AVGover 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
WorkerOperationunion 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
INsubqueries over logical-domain columns returned wrong results:WHERE amount = (SELECT amount ...)andWHERE amount IN (SELECT ...)onNUMERIC,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'scolumnDomainsmetadata reports the domain — which also means the Kysely driver'sresultDecodingapplies to scalar-subquery projections. Correlated subqueries were unaffected. CASTof aNUMERIC— or any other logical-domain — column to a number or boolean target read the internal tagged encoding instead of the value's text, soCAST(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 whoseBEGINthe 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 failedCOMMITleaves — 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 SQLNULL. Semantics follow PostgreSQL'sjsontype: a key of the wrong kind for the document's shape selectsNULL, withoutjsonb's scalar-as-array reading. The operators accept storedJSON/JSONBvalues and, as a Minnow extension, ordinary JSON text; a document that is not JSON is an error, matching the operators' typed-json strictness rather thanJSON_VALUE's quietNULL. The feature matrix gainsjson.arrow,json.arrow-text,json.arrow-index, andjson.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/kyselyrefuses the PostgreSQL forms Kysely's builders can produce but Minnow does not accept —DISTINCT ON,UPDATE ... FROM,DELETE ... USING, constraint-targetedON CONFLICT, row-locking clauses, schema-qualified names,ALTER TABLE ... RENAME, and emptyIN ()lists — at compile time with the feature named and an alternative offered, instead of surfacing the engine's bare parse error.@minnowdb/kyselytypes arithmetic scalar functions (round,abs,floor,ceil,mod,power,sqrt) over exactNUMERICcolumns as a compile-time refusal instead of anumberthe engine never produces. The check keys off the SQL parameter boundary, so enablingresultDecoding: { numeric: "number" }does not reintroduce the unsoundnumber;CASTtodouble precisionfirst, or use thefn.sum/fn.avgaggregates.
Changed
NUMERIC/DECIMALresults from a column with a declared scale now render at exactly that scale, matching PostgreSQL:1.5in aNUMERIC(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, andCAST(… AS NUMERIC(p, s))results. A bareNUMERICcolumn, derived arithmetic results, and values cast or concatenated to text still render canonically, without trailing fractional zeros, and a non-terminatingAVGquotient 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 withExpected identifier, found *, while the unquotedorders.*worked; query builders that quote every identifier, such as Kysely'sselectAll("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, andonStatsreports peak modeled execution memory. Every other statement checkssignalonce before it starts, so an already-aborted call never mutates anything. The widening coversMinnowDatabase,MinnowDatabaseClient(the signal and stats cross the worker channel exactly asquery's do), and theMinnowSqlExecutorcontract — a driver written against the two-argument form remains a valid implementation.@minnowdb/kyselyforwards Kysely'sAbortSignalto 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
WHEREthat is a conjunction ofcolumn = valueequalities 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.explainreports 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
structuredCloneper record, which was a sixth of a settle phase's CPU under sustained point updates. Values outside the plain shape still fall back tostructuredClone, 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/kyselyimplements Kysely's savepoint driver hooks, sostartTransaction(),savepoint(),rollbackToSavepoint(), andreleaseSavepoint()work against the engine's existingSAVEPOINTsupport.@minnowdb/kyselyserializes its single logical connection through a FIFO mutex in the driver itself rather than relying on Kysely's runtime to do so. Concurrentdb-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 JOINandRIGHT JOINbesideSELECT *,FULL JOINcombined with grouping/DISTINCT/window functions, numericRANGEframe offsets,DISTINCTwindow aggregates, window functions outside the select list, andON 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 belowOR,NOT,CASE, or select expressions while retaining SQL's three-valued and empty-set behavior. UPDATEandDELETEsubquery predicates now use the same resolution and decorrelation pipeline asSELECT; materialized subqueries automatically use the general mutation-selection executor. Target-qualified mutationRETURNINGcolumns andtarget.*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, andOFFSETare preserved, parameterized limits validate normally, and scalar cardinality errors remain deterministic.JSON_ARRAYAGGalso supports aggregate-local ordering in row, columnar, and spilled execution. @minnowdb/kyselynow exports fully typedjsonBuildObject,jsonArrayFrom, andjsonObjectFromhelpers from its root and/helpersentry points. They emit Minnow-native SQL-standard JSON constructors instead of PostgreSQL's opaquejson_build_object,json_agg, andto_jsonfragments.
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, andALLnow support non-equality comparisons,LATERALaccepts range correlation, grouped select lists accept scalars determined by their grouping keys, and correlatedEXISTS/NOT EXISTSwork 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/kyselyaddsresultDecoding: { numeric: "number", json: "parse" }. It consumes aligned domain metadata for buffered, streamed, and mutationRETURNINGrows, and its inferred select types follow the chosen mode. The lossless string boundary remains the default.- Nested
JSON_OBJECT,JSON_ARRAY,JSON_ARRAYAGG, andJSON_QUERYresults 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 (...) STOREDcolumns, 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
RETURNINGnow exposereturnedColumnsandreturnedColumnDomains, aligned withreturnedRows, so adapters can apply the same logical domain handling as ordinary queries and cursor pages. - Ordinary
query()calls now acceptQueryOptions.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. MinnowDatabaseClientnow supportssignalandonStatsfor 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
DatabaseReadBacklogErrorandLiveQueryLimitError, with stableinstanceofbehavior. - 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 successfulundefinedresult. MinnowDatabaseClientnow mirrors the direct catalog and index helpers:createView,dropView,dropColumn,dropTable,createIndex,dropIndex, andbuildFtsIndex.- Exact
NUMERICarithmetic 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.
DELETEstatements withoutRETURNINGnow 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/coreversion 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()andupsertBatch()accept aconflictWhereguard. Inserts still land, conflicting updates only replace a row when the existing row passes the predicate, andskippedRowCountreports 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 withcolumns. Declared columns and aliased expressions such asJSON_OBJECT/jsonBuildObjectnow 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. ItstableNameis stable for recovery code; matching error messages is unnecessary. - The schema DSL adds
column.date()for zoneless calendar dates. SQLDATE,CURRENT_DATE, and casts to DATE use canonicalYYYY-MM-DDstrings, while timestamps remain JavaScriptDateobjects. Kysely infers the same boundary. - Foreign keys accept
{ enforced: false }for catalog-only relationships. They appear inintrospect()but never reject a missing parent, cascade a delete, or create an index. @minnowdb/reactadds cold-start Suspense,useSuspenseLiveQuery(), andstaleWhileRevalidatesupport when switching stable query stores.
Migration
- Code that constructs a
QueryResultor cursor page itself must add acolumnDomainsarray with one domain ornullper result column. - Code that treated SQL
DATEorCURRENT_DATEas a JavaScriptDatemust use the canonical calendar string.TIMESTAMPbehavior did not change. - Replace missing-table message matching with
error instanceof UnknownTableErrorand readerror.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/clientrelease.
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.