From 55e1b0ad83b13f2e5ac16d1dc05c79e2be938c0c Mon Sep 17 00:00:00 2001 From: Nabil Belahouane Date: Sat, 24 Oct 2026 01:04:20 -0400 Subject: [PATCH 01/12] fix(electric): release the shape stream's visibility listener on cleanup The Electric client registers a visibilitychange listener (AppState on React Native) when a ShapeStream is constructed and removes it only in unsubscribeAll(). Sync cleanup unsubscribed its own callback and aborted the stream, so the listener stayed registered and kept the dead stream reachable. Each cleanup and restart, and each managed cache rotation, retained one more stream. Cleanup now calls stream.unsubscribeAll(). The stream is private to the sync session, so this affects no other consumer. A generated lane in the installed-SDK delivery oracle checks that cleanup leaves no listener from a run's streams across eager, on-demand and progressive runs that stay open, give up, abort or restart. The managed cache rotation case checks the retired session. --- .../electric-cleanup-stream-listeners.md | 5 + docs/contributing/oracle-coverage.md | 2 +- packages/db/tests/oracle-config.ts | 1 + .../electric-db-collection/src/electric.ts | 5 + .../tests/async-hold-outcomes.test.ts | 1 + ...ectric-descriptor-isolation-oracle.test.ts | 1 + .../tests/electric-live-query.test.ts | 1 + .../tests/electric-oracle.property.test.ts | 1 + .../tests/electric-recovery-oracle.test.ts | 1 + .../electric-resume-snapshot-races.test.ts | 1 + ...ctric-sdk-delivery-oracle.property.test.ts | 280 +++++++++++++++++- .../tests/electric.test.ts | 1 + .../electric-db-collection/tests/tags.test.ts | 1 + 13 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 .changeset/electric-cleanup-stream-listeners.md diff --git a/.changeset/electric-cleanup-stream-listeners.md b/.changeset/electric-cleanup-stream-listeners.md new file mode 100644 index 0000000000..9002c4cb87 --- /dev/null +++ b/.changeset/electric-cleanup-stream-listeners.md @@ -0,0 +1,5 @@ +--- +'@tanstack/electric-db-collection': patch +--- + +Release the shape stream's visibility listener when a sync run is cleaned up. The Electric client registers a `visibilitychange` listener (an `AppState` listener on React Native) for each `ShapeStream` and removes it only in `unsubscribeAll()`, so aborting the stream left the listener behind, and the listener kept the stream reachable. An app that cleans up and recreates collections, or whose on-demand cache rotates its provider session, gained one retained stream each time. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a085dd75d9..0b63c00a68 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -636,7 +636,7 @@ generic visitor behavior outside the named collectors. | QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, `packages/db/tests/transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md) | A finite array/Set model checks the compiled output bag for a joined DISTINCT subquery with an outer WHERE. Live-query drivers check selected containers, no-select, functional, and predicate-literal user objects with IR-like fields, top-level and aggregate-subquery proxy-shaped fields, arrays of selected references, outer virtual-field filters on joined DISTINCT and nested aggregate QueryRefs, and a renamed no-select source. The transaction suite documents the existing development-browser duplicate-load guard. The bounded cases run in `test:oracles` or the full DB suite. Joined DISTINCT, nested and flat aggregate source-update histories pass. Ordered joined `findOne()` checks initial, singleton, empty, restored, and changed-join-key cuts; an unordered `findOne()` QueryRef on either side of a join checks default-key candidate deletion and reinsertion. A materialized joined `findOne()` supplies a separate receiving formulation. Other `singleResult` forms, join forms, schedules, and cross-copy value handling outside the duplicate-load guard remain unproved. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | -| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. | +| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with an up-to-date or a retried 503, then staying open, giving up, or aborting the external signal before cleanup, with restarts through `preload()`. It compares the live listener count with the number of active runs after each step. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the managed cache rotation case receives the law for a retired session. It observes the browser listener in jsdom, not React Native's AppState adapter. | | PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. A metadata-bearing `Collection.update` passes through an observed SDK watcher callback to the public and SQLite rows and an exact queued CRUD patch. Held callback ordering and synthetic zero-field cases remain controlled-only, and remote upload has no receiving fixture here. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The Browser coordinator RPC owner's per-election cells also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 46e6efb6b2..eba93fa818 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -40,6 +40,7 @@ const staticOracleProperties = [ `electric.persistence-interleaving`, `electric.sdk-snapshot-delivery`, `electric.sdk-dnf-membership`, + `electric.sdk-source-release`, `electric-recovery.publication-stream-convergence`, `sqlite-resume.startup-generation`, `sqlite-resume.managed-claim-read`, diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index bb5f8b6048..df8a804a3e 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -2756,6 +2756,11 @@ function createElectricSync>( shapeOptions.signal?.removeEventListener(`abort`, forwardExternalAbort) // Unsubscribe from the stream unsubscribeStream() + // Aborting the stream does not remove the visibility listener it + // registered when it was constructed, and that listener keeps the + // stream reachable. The stream is private to this session, so + // unsubscribeAll affects no other consumer. + stream.unsubscribeAll() // Abort the abort controller to stop the stream abortController.abort() if (transactionStarted) { diff --git a/packages/electric-db-collection/tests/async-hold-outcomes.test.ts b/packages/electric-db-collection/tests/async-hold-outcomes.test.ts index 42cd1f356b..96b0d9bc48 100644 --- a/packages/electric-db-collection/tests/async-hold-outcomes.test.ts +++ b/packages/electric-db-collection/tests/async-hold-outcomes.test.ts @@ -36,6 +36,7 @@ vi.mock(`@electric-sql/client`, async () => { ...actual, ShapeStream: vi.fn(() => ({ subscribe: mockSubscribe, + unsubscribeAll: vi.fn(), fetchSnapshot: mockFetchSnapshot, requestSnapshot: vi.fn(), })), diff --git a/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts b/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts index 6a4507b50a..922936f01e 100644 --- a/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts @@ -184,6 +184,7 @@ vi.mock(`@electric-sql/client`, async () => { onStreamSubscribed?.() return unsubscribe }, + unsubscribeAll: vi.fn(), // ShapeStream.requestSnapshot is legal only in changes_only mode. // The installed-SDK delivery oracle is the receiving witness. requestSnapshot: vi.fn(() => { diff --git a/packages/electric-db-collection/tests/electric-live-query.test.ts b/packages/electric-db-collection/tests/electric-live-query.test.ts index eb76fa5b91..5e83711db1 100644 --- a/packages/electric-db-collection/tests/electric-live-query.test.ts +++ b/packages/electric-db-collection/tests/electric-live-query.test.ts @@ -81,6 +81,7 @@ function expectNoRepeatedSnapshotRequests() { const mockStream = { subscribe: mockSubscribe, + unsubscribeAll: vi.fn(), fetchSnapshot: mockFetchSnapshot, requestSnapshot: async (...args: any) => { const result = await mockRequestSnapshot(...args) diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index fa62022c93..51bef72249 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -162,6 +162,7 @@ const mockSubscribe = vi.fn() const shapeId = `{"params":{"table":"test_table"},"url":"http://test-url"}` const mockStream = { subscribe: mockSubscribe, + unsubscribeAll: vi.fn(), requestSnapshot: vi.fn().mockResolvedValue(undefined), fetchSnapshot: vi.fn().mockResolvedValue({ metadata: {}, data: [] }), forceDisconnectAndRefresh: vi.fn().mockResolvedValue(undefined), diff --git a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts index cfeef06173..ecf23f543a 100644 --- a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts @@ -135,6 +135,7 @@ vi.mock(`@electric-sql/client`, async () => ({ ...(await vi.importActual(`@electric-sql/client`)), ShapeStream: vi.fn(() => ({ subscribe: mockSubscribe, + unsubscribeAll: vi.fn(), requestSnapshot: vi.fn().mockResolvedValue(undefined), fetchSnapshot: vi.fn().mockResolvedValue({ metadata: {}, data: [] }), forceDisconnectAndRefresh: vi.fn().mockResolvedValue(undefined), diff --git a/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts b/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts index 4d8174da91..d26bfe1696 100644 --- a/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts +++ b/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts @@ -33,6 +33,7 @@ vi.mock(`@electric-sql/client`, async () => ({ ...(await vi.importActual(`@electric-sql/client`)), ShapeStream: vi.fn(() => ({ subscribe: mockSubscribe, + unsubscribeAll: vi.fn(), requestSnapshot: vi.fn(() => snapshotRequest?.() ?? Promise.resolve()), fetchSnapshot: vi.fn().mockResolvedValue({ metadata: {}, data: [] }), forceDisconnectAndRefresh: vi.fn().mockResolvedValue(undefined), diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts index 832b2b8a0d..08e1e0ec64 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts @@ -18,6 +18,7 @@ import { oraclePropertyOptions, readOracleRunConfig, } from '../../db/tests/oracle-config' +import { createOracleReach } from '../../db/tests/oracle-reach' import { managedTagPersistence, storageTargetId, @@ -82,6 +83,8 @@ import type { Message } from '@electric-sql/client' * A real node:sqlite cold-launch witness starts with an incompatible partial * cache and no reset marker. It must exclude that row before and after the * installed SDK returns an empty source snapshot for the demanded predicate. + * A source-release lane, documented at its property, checks that cleanup + * removes every listener that a run's streams registered on `document`. * * These finite HTTP fixtures do not prove that a live Electric service emits * the authored snapshot or move frames, nor do they cover multiple row keys, @@ -309,6 +312,29 @@ function controlledHttp() { } } +// Records the `visibilitychange` listeners that code registers on `document`. +function recordVisibilityListeners() { + const add = vi.spyOn(document, `addEventListener`) + const remove = vi.spyOn(document, `removeEventListener`) + const visibility = (calls: Array>) => + calls + .filter(([type]) => type === `visibilitychange`) + .map(([, listener]) => listener) + return { + registered: () => visibility(add.mock.calls).length, + live: () => { + const removed = new Set(visibility(remove.mock.calls)) + return visibility(add.mock.calls).filter( + (listener) => !removed.has(listener), + ).length + }, + restore: () => { + add.mockRestore() + remove.mockRestore() + }, + } +} + // Deliver a coordinator notification through the same persistence wrapper // used by the installed-SDK receiver. The test still controls the provider // response independently of this durable-cache notification. @@ -835,10 +861,14 @@ fixedCase(`does not certify an aborted SDK subset snapshot`, async () => { * its own snapshots. This receives the controlled provider-restart law through * the installed ShapeStream and HTTP path. A Map-backed adapter supplies the * generation boundary; real SQLite and native hosts have separate owners. + * It also receives the stream-listener release law below for the retired + * session: after the rotation only the replacement stream's listener stays + * registered, and none stays after cleanup. */ fixedCase( `discards an old SDK snapshot after managed cache rotation`, async () => { + const listeners = recordVisibilityListeners() const http = controlledHttp() const rows = new Map>([ [`old-generation`, new Map()], @@ -1031,7 +1061,14 @@ fixedCase( expect(collection.get(1)).toBeUndefined() expect(collection.get(2)?.name).toBe(`fresh`) expect([...rows.get(`new-generation`)!.keys()]).toEqual([2]) - }, [() => collection.cleanup(), () => http.close()]) + expect(listeners.registered()).toBe(2) + expect(listeners.live(), `live listeners after rotation`).toBe(1) + }, [ + () => collection.cleanup(), + () => http.close(), + () => expect(listeners.live(), `live listeners after cleanup`).toBe(0), + () => listeners.restore(), + ]) }, ) @@ -2164,3 +2201,244 @@ fixedCase.each(clientRetryCells)( (outcome, mode, decision) => runClientRetryCell(outcome, mode, decision), 60_000, ) + +/** + * # Cleanup releases the installed client's stream listeners + * + * Cleanup ends a sync run and releases its adapter-owned resources + * (`CleanupFn` in `packages/db/src/types.ts`, and "Sync Implementation" in the + * collection options creator guide). The Electric adapter constructs one + * ShapeStream per provider session. The installed client registers a + * `visibilitychange` listener on `document` for each stream, so that the + * stream pauses while the page is hidden. That listener keeps the stream + * reachable until the client releases it. The law: after cleanup, `document` + * holds no `visibilitychange` listener from a stream of the cleaned-up run, + * whatever state the source reached. While a run is active, it holds at most + * the listener of its one stream. + * + * The model is the number of active sync runs, 0 or 1. It reads no adapter or + * client state. A run is active from its start until its cleanup, and each run + * constructs one stream, so the expected number of live listeners is at most + * the number of active runs, and exactly 0 once no run is active. The model + * does not predict when an active stream releases its listener, because the + * client may release it early, for example when its signal aborts. + * + * The history grammar has a sync mode and one to three sync runs. The first + * run starts with the Collection; each later run is a restart of the + * cleaned-up Collection through `preload()`. In each run the provider answers + * zero to three requests, each with a row and an up-to-date, or with a 503 + * that `onError` retries. The run then stays open, gives up on one more 503 + * because `onError` returns no retry, or aborts the external signal. Cleanup + * ends the run. The signal stays aborted, so a run that starts after an abort + * starts with an aborted signal and sends no request. The provider answers + * only a request the client sent. The grammar does not answer a request after + * a give-up or an abort, because the client sends none. + * + * The production driver uses a real Collection, the Electric adapter, the + * installed ShapeStream, and controlled HTTP. The recorder wraps + * `document.addEventListener` and `document.removeEventListener` and counts + * the `visibilitychange` listeners still registered. The comparison runs after + * each provider answer, give-up, abort, and cleanup, once the client has sent + * its next request or decided to send none. A calibration check at the end + * shows that the recorder saw one listener for each run. + * + * Limits: the lane observes the browser listener in jsdom. Client versions + * that observe React Native's AppState instead register through a runtime + * adapter, which this lane does not install. A live Electric service and + * snapshot requests are outside this lane. The managed cache rotation case + * above replaces a provider session within one run; it receives the same law + * for the retired session. + */ +type ReleaseRun = { + answers: Array<`up-to-date` | `retry`> + end: `open` | `give-up` | `abort` +} +type ReleaseHistory = { + mode: `eager` | `on-demand` | `progressive` + runs: Array +} +type ReleaseState = + | `cleanup-while-loading` + | `cleanup-after-ready` + | `cleanup-after-give-up` + | `cleanup-after-abort` + | `cleanup-of-restarted-run` + +const createReleaseReach = () => + createOracleReach(`electric.sdk-source-release`, [ + `cleanup-while-loading`, + `cleanup-after-ready`, + `cleanup-after-give-up`, + `cleanup-after-abort`, + `cleanup-of-restarted-run`, + ]) +// Each campaign declares its own reach. +let releaseReach = createReleaseReach() + +async function checkSourceRelease(history: ReleaseHistory): Promise { + releaseReach.beginHistory() + const listeners = recordVisibilityListeners() + const http = controlledHttp() + const external = new AbortController() + let retries = true + let onErrorCalls = 0 + const collection = createCollection( + electricCollectionOptions({ + id: `sdk-release-${++sequence}`, + shapeOptions: { + url: `http://test-url/release-${sequence}`, + params: { table: `rows` }, + fetchClient: http.fetchClient, + signal: external.signal, + backoffOptions: { + initialDelay: 0, + maxDelay: 0, + multiplier: 1, + maxRetries: 0, + }, + onError: () => { + onErrorCalls++ + return retries ? {} : undefined + }, + }, + syncMode: history.mode, + startSync: true, + getKey: (row) => row.id, + }), + ) + + // The model is `activeRuns`. `sending` and `ready` track the source only to + // choose legal provider steps and to classify reach. + let activeRuns = 1 + let runsStarted = 1 + let sending = true + let ready = false + let offset = 0 + + const check = (label: string) => + expect(listeners.live(), `${label}: live listeners`).toBeLessThanOrEqual( + activeRuns, + ) + const next = (label: string) => + atCheckpoint(http.take(), `${label} HTTP request`) + + const consoleError = vi.spyOn(console, `error`).mockImplementation(() => {}) + const consoleWarn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + await withElectricCleanup(async () => { + for (const [index, run] of history.runs.entries()) { + if (index > 0) { + void collection.preload().catch(() => undefined) + activeRuns = 1 + runsStarted++ + sending = !external.signal.aborted + ready = false + } + let request = sending ? await next(`run ${index} start`) : undefined + for (const answer of run.answers) { + if (!request) break + if (answer === `up-to-date`) { + offset++ + request.respond( + new Response( + JSON.stringify([ + { + key: `1`, + value: { id: `1`, name: `row` }, + headers: { operation: `insert` }, + }, + { + headers: { + control: `up-to-date`, + global_last_seen_lsn: String(offset), + }, + }, + ]), + { headers: headers(offset) }, + ), + ) + ready = true + } else { + request.respond(new Response(`transient`, { status: 503 })) + } + request = await next(`run ${index} ${answer}`) + check(`run ${index} ${answer}`) + } + if (request && run.end === `give-up`) { + const calls = onErrorCalls + retries = false + request.respond(new Response(`transient`, { status: 503 })) + await vi.waitFor(() => expect(onErrorCalls).toBe(calls + 1)) + retries = true + sending = false + check(`run ${index} give-up`) + } else if (run.end === `abort` && !external.signal.aborted) { + external.abort() + sending = false + check(`run ${index} abort`) + } + + releaseReach.reach( + external.signal.aborted + ? `cleanup-after-abort` + : !sending + ? `cleanup-after-give-up` + : ready + ? `cleanup-after-ready` + : `cleanup-while-loading`, + ) + if (index > 0) releaseReach.reach(`cleanup-of-restarted-run`) + await collection.cleanup() + activeRuns = 0 + check(`run ${index} cleanup`) + } + // Calibration: the recorder observed the listener of every run's stream. + expect(listeners.registered()).toBe(runsStarted) + }, [ + () => collection.cleanup(), + () => http.close(), + () => listeners.restore(), + () => consoleError.mockRestore(), + () => consoleWarn.mockRestore(), + ]) +} + +const releaseViolation = sameViolation() +const releaseProperty = fc.asyncProperty( + fc.record({ + mode: fc.constantFrom( + `eager` as const, + `on-demand` as const, + `progressive` as const, + ), + runs: fc.array( + fc.record({ + answers: fc.array( + fc.constantFrom(`up-to-date` as const, `retry` as const), + { maxLength: 3 }, + ), + end: fc.constantFrom( + `open` as const, + `give-up` as const, + `abort` as const, + ), + }), + { minLength: 1, maxLength: 3 }, + ), + }), + (history: ReleaseHistory) => + releaseViolation.check(history, () => checkSourceRelease(history)), +) +sdkCampaigns( + `electric.sdk-source-release`, + `releases every installed-SDK stream listener when a sync run is cleaned up`, + 20261024, + releaseViolation, + async (seed) => { + releaseReach = createReleaseReach() + await fc.assert(releaseProperty, { + ...oraclePropertyOptions(40, `electric.sdk-source-release`), + ...(seed === undefined ? {} : { seed }), + }) + releaseReach.assertReached() + }, +) diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 3f0aa80c5f..7c30e63c3a 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -44,6 +44,7 @@ const mockFetchSnapshot = vi.fn() const mockForceDisconnectAndRefresh = vi.fn() const mockStream = { subscribe: mockSubscribe, + unsubscribeAll: vi.fn(), requestSnapshot: mockRequestSnapshot, fetchSnapshot: mockFetchSnapshot, forceDisconnectAndRefresh: mockForceDisconnectAndRefresh, diff --git a/packages/electric-db-collection/tests/tags.test.ts b/packages/electric-db-collection/tests/tags.test.ts index 48af9c6b15..65cfafc13a 100644 --- a/packages/electric-db-collection/tests/tags.test.ts +++ b/packages/electric-db-collection/tests/tags.test.ts @@ -20,6 +20,7 @@ const mockRequestSnapshot = vi.fn() const mockFetchSnapshot = vi.fn() const mockStream = { subscribe: mockSubscribe, + unsubscribeAll: vi.fn(), requestSnapshot: mockRequestSnapshot, fetchSnapshot: mockFetchSnapshot, } From a8477a5c8dd4e5d2ab06a3172a97a59a2f222f0a Mon Sep 17 00:00:00 2001 From: Nabil Belahouane Date: Sat, 24 Oct 2026 01:17:36 -0400 Subject: [PATCH 02/12] test(electric): name the source-release property in the oracle replay manifest --- packages/db/tests/oracle-replay-manifest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 7bcf5f9337..94f1e1b606 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -77,7 +77,7 @@ const ownerGroups: ReadonlyArray = [ [ `electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts`, `electric`, - `sdk-snapshot-delivery sdk-dnf-membership`, + `sdk-snapshot-delivery sdk-dnf-membership sdk-source-release`, ], [ `db/tests/collection-sync-reentrancy-oracle.test.ts`, From 3075725a140acec84f9054881f6c69d1bc120cb6 Mon Sep 17 00:00:00 2001 From: Nabil Belahouane Date: Sun, 25 Oct 2026 12:33:54 -0400 Subject: [PATCH 03/12] docs(electric): name the client versions that register an AppState listener --- .changeset/electric-cleanup-stream-listeners.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/electric-cleanup-stream-listeners.md b/.changeset/electric-cleanup-stream-listeners.md index 9002c4cb87..4faea3325e 100644 --- a/.changeset/electric-cleanup-stream-listeners.md +++ b/.changeset/electric-cleanup-stream-listeners.md @@ -2,4 +2,4 @@ '@tanstack/electric-db-collection': patch --- -Release the shape stream's visibility listener when a sync run is cleaned up. The Electric client registers a `visibilitychange` listener (an `AppState` listener on React Native) for each `ShapeStream` and removes it only in `unsubscribeAll()`, so aborting the stream left the listener behind, and the listener kept the stream reachable. An app that cleans up and recreates collections, or whose on-demand cache rotates its provider session, gained one retained stream each time. +Release the shape stream's visibility listener when a sync run is cleaned up. The Electric client registers a `visibilitychange` listener for each `ShapeStream` (an `AppState` subscription on React Native, from `@electric-sql/client` 1.5.23) and removes it only in `unsubscribeAll()`, so aborting the stream left the listener behind, and the listener kept the stream reachable. An app that cleans up and recreates collections, or whose on-demand cache rotates its provider session, gained one retained stream each time. From 1a5ca0edeacf0600f6f6a0d04df32ea90b7a4bea Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 11:01:04 -0600 Subject: [PATCH 04/12] test(electric): cover incomplete stream cleanup and test teardown Generate row-only delivery and HTTP 409 reset before cleanup, with replay examples across modes and terminal outcomes. Release recorded listeners after failed comparisons and preserve the primary error. Law: completed sync runs release their resources (CleanupFn and creator guide). Reach: fixed 40-history campaign reaches partial cleanup in 4 histories and reset cleanup in 10; the random campaign reaches both too. RED: old recorder fails actual event dispatch after teardown; original missing release and both conditional-release variants fail expanded cleanup checks. GREEN: 1,130 package tests pass with no type errors; lint and builds pass. Calibration: direct seed/path replay fails each incorrect cleanup variant and passes unchanged PR production. Production source is unchanged. Limits: installed SDK, controlled HTTP and jsdom; no native/live-service claim. Full review evaluation and exact-source evidence: docs/contributing/oracle-reviews/2026-10-26-pr-2257.md --- docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/2026-10-26-pr-2257.md | 350 ++++++++++++++++++ ...ctric-sdk-delivery-oracle.property.test.ts | 124 ++++++- 3 files changed, 456 insertions(+), 20 deletions(-) create mode 100644 docs/contributing/oracle-reviews/2026-10-26-pr-2257.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a41390926c..d0a21780d4 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -636,7 +636,7 @@ generic visitor behavior outside the named collectors. | QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, `packages/db/tests/transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md) | A finite array/Set model checks the compiled output bag for a joined DISTINCT subquery with an outer WHERE. Live-query drivers check selected containers, no-select, functional, and predicate-literal user objects with IR-like fields, top-level and aggregate-subquery proxy-shaped fields, arrays of selected references, outer virtual-field filters on joined DISTINCT and nested aggregate QueryRefs, and a renamed no-select source. The transaction suite documents the existing development-browser duplicate-load guard. The bounded cases run in `test:oracles` or the full DB suite. Joined DISTINCT, nested and flat aggregate source-update histories pass. Ordered joined `findOne()` checks initial, singleton, empty, restored, and changed-join-key cuts; an unordered `findOne()` QueryRef on either side of a join checks default-key candidate deletion and reinsertion. A materialized joined `findOne()` supplies a separate receiving formulation. Other `singleResult` forms, join forms, schedules, and cross-copy value handling outside the duplicate-load guard remain unproved. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | -| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with an up-to-date or a retried 503, then staying open, giving up, or aborting the external signal before cleanup, with restarts through `preload()`. It compares the live listener count with the number of active runs after each step. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the managed cache rotation case receives the law for a retired session. It observes the browser listener in jsdom, not React Native's AppState adapter. | +| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, or aborting the external signal before cleanup, with restarts through `preload()`. It declares separate cleanup reach for partial delivery and reset before a later up-to-date. The minimal partial/reset histories cross each terminal outcome in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the managed cache rotation case receives the law for a retired session. It observes the browser listener in jsdom, not React Native's AppState adapter. | | PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. A metadata-bearing `Collection.update` passes through an observed SDK watcher callback to the public and SQLite rows and an exact queued CRUD patch. Held callback ordering and synthetic zero-field cases remain controlled-only, and remote upload has no receiving fixture here. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The Browser coordinator RPC owner's per-election cells also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md new file mode 100644 index 0000000000..5d07d44f34 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -0,0 +1,350 @@ +# PR #2257 oracle review — 2026-10-26 + +Reviewed PR: https://github.com/TanStack/db/pull/2257 + +Reviewed head: `b585f92f20de9cd89a6783ac67784d4bdf9b7a18`. +Four independent reviewers used the repository's oracle-review skill to examine +contract authority, generated histories, listener recording, and deliberately +incorrect cleanup variants. Oracle-authoring was read before experimental test +changes. Production and oracle source in the shared review checkout remain unchanged. + +## Findings + +### P2: Generate cleanup during partial delivery and reset + +Location: `packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts:2415-2417`, with the response driver at 2337-2359. + +Every successful generated response includes both a row and `up-to-date`. +Consequently `cleanup-while-loading` means no delivered rows or only retries; +it never includes a transaction opened by a partial row batch, or an active +reset awaiting replacement data. The law promises listener release whatever +state the source reached. These are legal ordinary stream histories: +`electric-sdk-framing.test.ts:29-35` already demonstrates row-only responses +and HTTP 409 `must-refetch` delivery through the real SDK. + +Two single-condition changes to the production cleanup pass the existing +fixed/random listener campaigns and managed-cache rotation case: + +```ts +if (!transactionStarted) stream.unsubscribeAll() +// Separately: +if (!isResettingSnapshot) stream.unsubscribeAll() +``` + +The first also passes all 42 existing SDK delivery tests. A row-only response, +next actual HTTP request, then cleanup distinguishes the first change: the PR +leaves zero listeners; the incorrect variant leaves one. An initial HTTP 409 +`must-refetch`, next actual request, then cleanup distinguishes both changes +in eager, on-demand, and progressive modes. The Collection remains `loading` +before cleanup, and the final listener comparison is the failing assertion. + +Repair the primary history grammar with row-only and reset answers separate +from `up-to-date`. Permit cleanup before that later marker and declare reach +for partial-delivery and reset cleanup. Include nearby retry, give-up, abort, +and restart histories when claiming coverage of the full stated class. Keep +the independent active-run model; do not copy production transaction/reset +flags into it. This finding concerns oracle coverage. The PR's unconditional +production release passes these cases. + +### P2: Release retained listeners after a failing test + +Location: `packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts:331-334` and 2396-2401. + +The recorder's `restore()` restores method spies but leaves any remaining DOM +listener registered. With the original missing release, Collection cleanup +already completed; repeating that idempotent cleanup cannot remove the +listener. Thus each failing or shrinking run leaves the dead SDK stream +reachable from the shared document. ORC-010 requires test resources to be +released on failure too. + +A direct real-SDK/jsdom diagnostic recorded one actual listener after the +original cleanup, one after exact recorder restoration, and zero after +explicit SDK release. This is a diagnostic plus source tracing of the test's +cleanup path, not a complete generated-campaign teardown experiment. + +Preserve the primary assertion, then remove remaining registrations owned by +the recorder during final teardown, or retain stream handles for independent +test cleanup. Add a deliberate failure case showing both the original +mismatch and final resource release. Do not remove listeners before comparing. + +### Review-evidence gap: preserve the final evidence with the reviewed revision + +The PR adds a new property but no versioned exact-head review record. Its +reported RED/GREEN results, reach counts, and rejected incorrect variants +live in the editable PR description. ORC-012 requires verdict-critical +external evidence in a versioned record, including applicable requirement +outcomes and reasons for non-applicable ones. This does not imply the reported +runs did not occur. This local review record is tied to the reviewed head; +it has not been committed or published by this review. + +## Contract and scope + +`CleanupFn` in `packages/db/src/types.ts:420-429` and “Sync Implementation” in +`docs/guides/collection-options-creator.md:70-78` authorize release of resources +owned by a completed sync run. After completed cleanup, no run-owned visibility +listener may remain. Counting active runs independently of the adapter is +sufficient for that upper bound. Allowing zero listeners before cleanup keeps +valid freedom for a client that releases earlier, such as on abort. No +unsupported production defect or contract revision was established. + +The production path is a real Collection, the Electric adapter, installed +`@electric-sql/client` 1.5.15, controlled HTTP, and jsdom document registrations. +Experiments used archived copies of the reviewed head and read-only dependency +artifacts. Explicit Vite resolution confirms core and db-ivm source came from +the reviewed archive, not the dirty primary checkout. Scratch source changes +were restored. Coverage instrumentation and TypeScript checking were disabled +for runtime experiments; nonfatal unrelated Expo tsconfig diagnostics appeared. +No setup failure or timeout was counted as an assertion rejection. + +## Executed evidence + +| Case | Reviewed production | Incorrect cleanup variant | +| --- | --- | --- | +| New fixed/random listener campaigns + rotation | 3 pass | Both variants: 3 pass | +| Full existing SDK delivery file | 42 pass | Transaction-condition variant: 42 pass | +| Row-only response then cleanup | 1 pass | Transaction-condition variant: listener assertion fails, 1 versus 0 | +| HTTP 409 reset then cleanup, three modes | 3 pass | Both variants: 3 listener assertion failures, 1 versus 0 | +| Actual listener after recorder restoration | Fixed cleanup releases it | Original missing release: 1 remains; independent SDK teardown removes it | + +Evidence is retained in `review-evidence/pr2257-oracle-review/`. The partial +case patch appends to the reviewed oracle; the reset case text likewise uses +its existing helpers. Exact incorrect variants are the two lines above. + +From the experimental snapshot's `packages/electric-db-collection` directory, +reviewers used the installed Vitest entry point directly: + +```sh +node /Users/kyle.mathews/programs/tanstack-db/node_modules/vitest/vitest.mjs run tests/electric-sdk-delivery-oracle.property.test.ts --coverage.enabled=false --typecheck.enabled=false +``` + +Selectors restricted individual runs to the listener campaigns, rotation, or +appended review cases. Raw logs distinguish each execution and comparison. + +## Original failure and direct replay + +Removing only `stream.unsubscribeAll()` reproduces the original defect in both +listener campaigns: `run 0 cleanup: live listeners`, expected at most zero, +received one. The fixed campaign reduces to one eager run with no answers and +an open ending. Seed `20261024`, path `0:0:0:0`, and property +`electric.sdk-source-release` select exactly one replay test and reproduce the +same cleanup assertion. Restoring the reviewed production makes that identical +replay pass. Both scratch source files were restored to reviewed bytes. + +```sh +TANSTACK_DB_ORACLE_SEED=20261024 TANSTACK_DB_ORACLE_PATH=0:0:0:0 TANSTACK_DB_ORACLE_PROPERTY=electric.sdk-source-release node /Users/kyle.mathews/programs/tanstack-db/node_modules/vitest/vitest.mjs run tests/electric-sdk-delivery-oracle.property.test.ts -t replay --coverage.enabled=false --typecheck.enabled=false +``` + +Final review-checkout runtime validation: all 42 SDK delivery tests pass with +the installed local dependency override. `pnpm lint:changed` also passes. +Production and oracle source remain unchanged; only local installation files +and this review's report/evidence differ from the reviewed head. + +## ORC outcomes + +| Requirement | Outcome at reviewed head | +| --- | --- | +| 001 authority/limits | Supported by public cleanup sources; browser/native limits explicit. | +| 002 independent judgment | Active-run count reads no production classifier. | +| 003 literate responsibilities | Law, model, grammar, driver and comparison are explained together. | +| 004 grammar controls | Gap: row-only and reset cleanup require missing history rules. Other declared bounds/exclusions are stated. Full ablation evidence remains unverified. | +| 005 path/observation | Real SDK and cleanup listener comparison executed. No concrete missing-input hypothesis identified. | +| 006 calibration | Incorrect conditional variants survive existing checks and fail added cleanup comparisons; original missing-release replay recorded separately. | +| 007 campaigns/replay | Both existing fixed/random campaigns executed; direct original-failure replay recorded separately. | +| 008 minimality | Active versus cleaned-up run suffices for this resource law; source bookkeeping governs driver legality/reach. | +| 009 vocabulary | Sync runs/provider sessions distinguished; active-run abstraction explained. | +| 010 failure/cleanup | Primary mismatch preserved; retained DOM resource on failing teardown is a gap. | +| 011 second formulation | Not triggered: no supported shared semantic-model fault requiring another expected-result formulation. | +| 012 review evidence | PR lacks a versioned exact-head record; known reachable distinctions prevent a full coverage claim. | +| 013 distinguishing boundaries | Existing cleanup boundary works; partial-delivery/reset premises absent from generated histories. Added cases distinguish the incorrect conditions. | +| 014 receiving witness | Real installed SDK supplies listener/reset behavior; controlled HTTP supplies authored responses. No live-service or native result claimed. | +| 015 declared reach | Existing counters execute; loading reach does not establish partial-delivery/reset cleanup. Add those premises and report their counts. | + +## Remaining limits and setup + +No live Electric service or React Native AppState run was executed. General +EventTarget callback-reuse/capture semantics are not established by this +recorder, but the pinned SDK uses one fresh default-capture callback per +stream, so those theoretical recorder limitations are not additional findings. +Fixed managed-cache rotation is a valid receiving case; lack of arbitrary +rotation generation alone was not reported as a defect. + +The review worktree is detached at the exact PR head. No remote branch was +published. Dependency installation used `--ignore-scripts` and a local-only +pnpm override for `baseline-browser-mapping` from 2.11.32 to 2.11.29: +2.11.31 also returned 403, and 2.11.30 was absent from registry metadata. +The override and resulting lockfile changes must remain uncommitted. Global +`/Users/kyle.mathews/.codex/AGENTS.md` now records that recovery rule. + +## Repair and review evaluation — 2026-10-26 + +This entry evaluates the four raw reviews and records the authorized repairs. +The preceding entry describes the initial head, not the state after this entry. +Starting head: `b585f92f20de9cd89a6783ac67784d4bdf9b7a18`. +The tested oracle source SHA-256 is `4c997a507c116710a171c6703338b5b3964ff19f35b98ceac8124e9085486dad`. +The commit containing this entry identifies the corresponding repair tree; +production source is unchanged from the starting head. Raw reviewer notes, +full logs, and the append-only working ledger remain task-local rather than +being published as part of the change. + +### Laws, experiments, and inference + +| Law and authority | Previous evidence | Decisive experiment | Repair and remaining enforcement | +| --- | --- | --- | --- | +| Completed sync-run cleanup releases its resources (`CleanupFn`, creator guide). | Complete row batches, retries, terminal outcomes and restarts; incomplete rows/reset absent. | Two conditional-release variants passed the old checks but fail expanded generated cleanup comparisons, each leaving one listener instead of zero. | Generate independent row-only/reset answers; preserve pending authored answers across retries and terminal outcomes; clear them after up-to-date/reset/restart. Same active-run model and public cleanup comparison. No known open distinction in these bounded histories. | +| Test teardown releases owned registrations and preserves its primary failure (ORC-010). | Method spies restored; physical DOM registrations untested after a failure. | Before repair, retained callback fires after teardown. After repair, all eight subsets of three callbacks are removed and the identical primary Error survives. | Recorder finally removes captured registrations after comparison, then restores spies. An independent EventTarget-dispatch comparison checks actual release. No known open distinction for the fresh default-capture callbacks used by the installed SDK. | +| External review evidence remains tied to its reviewed revision (ORC-012). | Evidence only in editable PR prose. | Source inspection of the starting PR; exact-head initial report and tested-source digest recorded here. | This versioned record preserves findings, calibration, reach, limits, and requirement outcomes. This documentary obligation does not need an artificial runtime assertion. | + +The oracle grammar now accepts zero to three answers per run, from `row`, +`up-to-date`, `reset`, and `retry`; one to three runs; all three sync modes; +and open, give-up, or abort endings. The same driver also runs 18 minimal +histories: two incomplete answers × three endings × three modes. These remain +replay examples alongside generated nearby histories, rather than replacing +generation. Readiness reach reads public Collection status; pending-answer +reach derives only from provider inputs, without inspecting production +transaction/reset flags. + +Grammar controls: removing row-only or reset loses the demonstrated cases; +removing up-to-date loses completed-delivery comparisons; removing retry loses +incomplete delivery across retried failure; removing terminal endings loses +give-up/abort cleanup; removing modes loses their installed-SDK paths; removing +multiple runs loses cleanup/restart and already-aborted restart. Zero answers +covers immediate cleanup, three answers can cross reset, row and completion, +and three runs cover repeated release. Requests are answered only when the +real SDK sent them: after give-up or external abort, no later response is +invented. This is a bounded grammar, not a proof for arbitrary history length. + +### Lossless item ledger + +Raw claims and their original proposed repairs were copied into the working +ledger before new tests or edits. Source-order IDs remain distinct even where +multiple reviewers identified the same gap. + +| ID | Source claim | Evidence and technical verdict | PR action | Durable value / destination | +| --- | --- | --- | --- | --- | +| L01 | Cleanup authority and active-run upper bound are sound. | Public cleanup sources; early release remains allowed. | accepted-design | Preserve model and contract prose. | +| L02 | Partial delivery is missing; generate it before cleanup. | Conditional transaction release passed old full file, fails new campaigns. | fixed-now | Expanded grammar, new reach, minimal replay histories. | +| L03 | Failing/shrinking runs retain listeners; add independent final release. | Direct SDK diagnostic and old-recorder RED; bounded dispatch calibration GREEN. | fixed-now | Recorder teardown and primary-error calibration. | +| L04 | Exact-head versioned external evidence is absent. | Starting diff had no review record. | fixed-now | Initial-head record plus this repair entry. | +| H01 | Row-only cleanup needs a grammar rule; one example is insufficient. | Same-path real SDK witness; old 42-test survival retained as initial evidence. | duplicate of L02 | Independent source-resolution evidence and generated repair. | +| H02 | Already-aborted restart sends no requests; dead suffixes are intentionally ignored. | SDK request admission and driver conditions. | accepted-design | Preserve signal continuity and legal-history exclusion. | +| H03 | Fixed rotation is a distinct valid receiving case; arbitrary rotation absence alone is not a defect. | Existing replacement-session and final-zero checks remain. | accepted-design | Keep managed-cache rotation receiver and its stated limits. | +| H04 | Loading reach is too coarse; partial delivery needs declared reach and adjacent endings. | New partial/reset counters and generated retry/ending/restart combinations. | duplicate of L02 | Explicit partial/reset cleanup reach. | +| H05 | Original missing release and direct replay need calibration. | Expanded original-release RED and identical restored-source replay GREEN. | accepted-design | Replayed seed/path coordinates below. | +| R01 | Restoring spies leaves the actual original-defect listener alive. | Same diagnostic as L03; independent final cleanup now checked. | duplicate of L03 | Preserve physical-resource observation. | +| R02 | Recorder count ignores general callback reuse/capture ordering, but pinned SDK never reuses registrations that way. | SDK uses one fresh default-capture callback per stream. | accepted-design | Explicit narrow recorder domain; no general EventTarget counting claim. | +| R03 | Counts alone do not identify old versus current callbacks, but no in-domain surviving incorrect replacement was demonstrated. | Rotation and final zero remain observable; no new product-defect claim inferred. | accepted-design | Retain identity limitation without inventing a finding. | +| R04 | Requiring exactly one active listener would add unsupported timing policy. | Model law is an upper bound and exact zero after cleanup. | duplicate of L01 | Keep legitimate early release. | +| R05 | Warning suppression might erase secondary cleanup diagnostics. | Helper restores console spy before reporting failures. | refuted | Keep exhaustive helper and distinguish primary/secondary diagnostics. | +| R06 | Shrink filtering might erase the original failure. | Original checkpoint is retained; direct replay reproduces it. | refuted | Preserve failure identity and replay evidence. | +| M01 | Reset cleanup is also absent; generate reset separately in all modes. | Both conditional variants fail expanded campaigns; minimal reset cases cross every mode/ending. | duplicate of L02 | Distinct reset action and cleanup reach. | +| M02 | Active visibility behavior and request cancellation are neighboring obligations. | Release oracle explicitly judges resource ownership; surrounding Electric tests pass. | accepted-design | Keep partial-oracle scope and surrounding suite. | +| M03 | Browser/controlled-HTTP evidence does not establish native or live-service behavior. | Actual installed SDK path, explicit original limits. | accepted-design | Preserve external limits in oracle and coverage map. | +| S01 | During repair, source snapshot progress could be confused with public readiness in reach labels. | Independent rereview identified the distinction after reset. | fixed-now | Removed driver readiness flag; classify from public Collection status. | + +Original items: **18 = 3 fixed-now + 5 duplicates + 8 accepted-design + +2 refuted**. One additional repair-time item, S01, is fixed-now. No item is +deferred, awaiting a product decision, or confirmed-open. The final loss audit +returned to the four raw reports, including their non-finding hypotheses and +scope notes; all are represented above. The working ledger remains local. + +### Reviewer assessment + +- Law/authority reviewer: hire. Correct public authority and useful separation + of product correctness, coverage and documentary evidence; appropriately + limited source-only conclusions. +- History reviewer: strong hire. Demonstrated full-file acceptance of a wrong + conditional release, supplied a minimal legal case, and verified reviewed + source resolution rather than assuming dependency reuse was safe. +- Recorder reviewer: hire. Found a real failure-path resource issue, calibrated + physical registrations, and did not promote out-of-domain counting concerns + into unsupported findings. +- Independent variant reviewer: strong hire. Supplied the distinct reset case + across all modes and a second incorrect condition. Correctly distinguished + a coverage defect from a production defect. + +Prioritization and proposed repairs were sound. The initial direct SDK teardown +diagnostic was narrower than a full generated failure-path test; the new +bounded calibration supplies executable regression evidence. Independent +rereviews found no substantive issue with the grammar or teardown repair; +the readiness wording caveat was resolved by S01. + +### RED, GREEN, and replay evidence + +Unchanged PR production passes the expanded resource-release campaigns. +Before recorder repair, the new teardown calibration fails because a retained +callback is called once after cleanup; the identical test passes after repair. +Thus no product defect is attributed to this test-only update. + +Each incorrect production variant is run separately in an archived checkout +with the updated oracle. Both fixed/random campaigns reject each at the +post-cleanup listener assertion, not setup, timeout, or unrelated errors: + +| Variant | Fixed seed | Shrink path | Reduced case | +| --- | --- | --- | --- | +| Omit `stream.unsubscribeAll()` | 20261024 | `0:0:0:0` | One eager run, no answers, open ending. | +| Release only if `!transactionStarted` | 20261024 | `5:0:1:1:1:1` | One eager run, row-only answer, open ending. | +| Release only if `!isResettingSnapshot` | 20261024 | `5:0:1:2:2` | One eager run, reset answer, open ending. | + +Each captured path was replayed directly with +`TANSTACK_DB_ORACLE_PROPERTY=electric.sdk-source-release`, the listed seed/path, +and `-t replay`; each incorrect source fails the same cleanup assertion and +unchanged PR production passes the identical replay. Experimental source is +restored after execution. + +Reach counts are histories, not sync-run totals. A scratch copy logged the +existing reach counters without changing generation, comparison or the +40-history campaign budget: + +| Declared state | Fixed seed 20261024 | Unseeded campaign | +| --- | --- | --- | +| cleanup-while-loading | 10 | 10 | +| cleanup-after-ready | 8 | 5 | +| cleanup-after-give-up | 16 | 16 | +| cleanup-after-abort | 19 | 19 | +| cleanup-of-restarted-run | 27 | 25 | +| cleanup-after-partial-delivery | 4 | 15 | +| cleanup-during-reset | 10 | 12 | + +Commands from the repository root: + +```sh +pnpm --dir packages/electric-db-collection exec vitest run --coverage.enabled=false +pnpm exec eslint packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +pnpm lint:changed +pnpm --filter @tanstack/electric-db-collection... build +``` + +The Electric package run passes 1,130 runtime tests (one skipped), with no type +errors. The changed-file ESLint and oracle-rule checks pass. Builds pass after +linking required cached workspace dependencies. Initial missing dependency and +build-output errors were setup failures, not successful checks. Production +source has no diff, so shipped production-byte weight is unchanged; only tests +and contract/evidence documentation grow. No new changeset is needed for this +test-only follow-up to the PR's existing release changeset. + +### Final requirement outcomes and limits + +ORC-001/002/003: authority, independent active-run judgment, and literate layers +preserved. ORC-004: row/reset reconstruction plus ablation/range/exclusion +accounted for above. ORC-005: real SDK, actual HTTP request fences, listener +comparisons after every answer/terminal step/completed cleanup; no concrete +missing-input hypothesis. ORC-006/007: original and two conditional variants +rejected by fixed/random campaigns; direct failing and passing replays checked. +ORC-008/009: model unchanged; authored pending answers are explicitly driver +reach bookkeeping and public readiness is read directly. ORC-010: primary +failure preserved and actual registrations released by the calibrated finally +path. ORC-011 is not triggered: no supported shared semantic-model fault needs +a second expected-result formulation. ORC-012: this versioned exact-base and +source-digest record closes the documentary gap. ORC-013: row/reset premises +and nearby completed/terminal histories distinguish the incorrect conditions. +ORC-014: installed SDK supplies stream/reset/listener behavior; controlled HTTP +supplies responses. ORC-015: seven declared reaches are checked per campaign, +including the two previously missing premises. + +The claims remain bounded to these generated legal histories, three sync modes, +the installed SDK, controlled HTTP, browser document in jsdom, and existing +managed-cache rotation receiver. There is no live-service or React Native +AppState execution claim. There is no known reachable counterexample inside +this bounded repair claim; sampled campaigns are not a universal proof. +Whole-PR merge readiness is **not assessed** by this review evaluation; remote +CI and later PR changes remain separate from the local checks above. diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts index 08e1e0ec64..c71c9c099c 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts @@ -329,12 +329,55 @@ function recordVisibilityListeners() { ).length }, restore: () => { - add.mockRestore() - remove.mockRestore() + // The comparison has already run. Release even a failed subject's + // registrations so RED runs and shrinking do not retain dead streams. + try { + for (const [type, listener, options] of add.mock.calls) + if (type === `visibilitychange`) + document.removeEventListener(type, listener, options) + } finally { + add.mockRestore() + remove.mockRestore() + } }, } } +// Teardown must preserve a failed comparison and release its registrations. +// Enumerate every retained subset of three fresh callbacks. Dispatch observes +// actual EventTarget ownership independently of the recorder's live count. +fixedCase( + `releases recorded listeners after preserving a primary failure`, + async () => { + for (let retained = 0; retained < 8; retained++) { + const listeners = recordVisibilityListeners() + const callbacks = Array.from({ length: 3 }, () => vi.fn()) + const primary = new Error(`listener comparison failed`) + try { + await expect( + withElectricCleanup(() => { + for (const [index, callback] of callbacks.entries()) { + document.addEventListener(`visibilitychange`, callback) + if ((retained & (1 << index)) === 0) + document.removeEventListener(`visibilitychange`, callback) + } + expect(listeners.registered()).toBe(3) + throw primary + }, [() => listeners.restore()]), + ).rejects.toBe(primary) + document.dispatchEvent(new Event(`visibilitychange`)) + for (const callback of callbacks) + expect(callback).not.toHaveBeenCalled() + } finally { + // Independent cleanup also makes this calibration safe on the old recorder. + for (const callback of callbacks) + document.removeEventListener(`visibilitychange`, callback) + listeners.restore() + } + } + }, +) + // Deliver a coordinator notification through the same persistence wrapper // used by the installed-SDK receiver. The test still controls the provider // response independently of this durable-cache notification. @@ -2226,8 +2269,10 @@ fixedCase.each(clientRetryCells)( * The history grammar has a sync mode and one to three sync runs. The first * run starts with the Collection; each later run is a restart of the * cleaned-up Collection through `preload()`. In each run the provider answers - * zero to three requests, each with a row and an up-to-date, or with a 503 - * that `onError` retries. The run then stays open, gives up on one more 503 + * zero to three requests: a row-only batch, a row with an up-to-date, an HTTP + * 409 reset, or a 503 that `onError` retries. Rows and resets may remain + * incomplete when cleanup starts; neither needs a later up-to-date first. + * The run then stays open, gives up on one more 503 * because `onError` returns no retry, or aborts the external signal. Cleanup * ends the run. The signal stays aborted, so a run that starts after an abort * starts with an aborted signal and sends no request. The provider answers @@ -2250,7 +2295,7 @@ fixedCase.each(clientRetryCells)( * for the retired session. */ type ReleaseRun = { - answers: Array<`up-to-date` | `retry`> + answers: Array<`row` | `up-to-date` | `reset` | `retry`> end: `open` | `give-up` | `abort` } type ReleaseHistory = { @@ -2263,6 +2308,8 @@ type ReleaseState = | `cleanup-after-give-up` | `cleanup-after-abort` | `cleanup-of-restarted-run` + | `cleanup-after-partial-delivery` + | `cleanup-during-reset` const createReleaseReach = () => createOracleReach(`electric.sdk-source-release`, [ @@ -2271,6 +2318,8 @@ const createReleaseReach = () => `cleanup-after-give-up`, `cleanup-after-abort`, `cleanup-of-restarted-run`, + `cleanup-after-partial-delivery`, + `cleanup-during-reset`, ]) // Each campaign declares its own reach. let releaseReach = createReleaseReach() @@ -2307,13 +2356,14 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { }), ) - // The model is `activeRuns`. `sending` and `ready` track the source only to - // choose legal provider steps and to classify reach. + // The model is `activeRuns`. `sending` chooses legal provider steps. + // `pendingAnswers` records authored rows/resets since the latest up-to-date, + // not adapter internals; readiness reach uses public Collection status. let activeRuns = 1 let runsStarted = 1 let sending = true - let ready = false let offset = 0 + const pendingAnswers = new Set<`row` | `reset`>() const check = (label: string) => expect(listeners.live(), `${label}: live listeners`).toBeLessThanOrEqual( @@ -2331,12 +2381,12 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { activeRuns = 1 runsStarted++ sending = !external.signal.aborted - ready = false + pendingAnswers.clear() } let request = sending ? await next(`run ${index} start`) : undefined for (const answer of run.answers) { if (!request) break - if (answer === `up-to-date`) { + if (answer === `row` || answer === `up-to-date`) { offset++ request.respond( new Response( @@ -2346,17 +2396,33 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { value: { id: `1`, name: `row` }, headers: { operation: `insert` }, }, - { - headers: { - control: `up-to-date`, - global_last_seen_lsn: String(offset), - }, - }, + ...(answer === `up-to-date` + ? [ + { + headers: { + control: `up-to-date`, + global_last_seen_lsn: String(offset), + }, + }, + ] + : []), ]), { headers: headers(offset) }, ), ) - ready = true + if (answer === `up-to-date`) { + pendingAnswers.clear() + } else pendingAnswers.add(`row`) + } else if (answer === `reset`) { + offset++ + request.respond( + new Response( + JSON.stringify([{ headers: { control: `must-refetch` } }]), + { status: 409, headers: { 'electric-handle': `shape` } }, + ), + ) + pendingAnswers.clear() + pendingAnswers.add(`reset`) } else { request.respond(new Response(`transient`, { status: 503 })) } @@ -2382,11 +2448,15 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { ? `cleanup-after-abort` : !sending ? `cleanup-after-give-up` - : ready + : collection.status === `ready` ? `cleanup-after-ready` : `cleanup-while-loading`, ) if (index > 0) releaseReach.reach(`cleanup-of-restarted-run`) + if (pendingAnswers.has(`row`)) + releaseReach.reach(`cleanup-after-partial-delivery`) + if (pendingAnswers.has(`reset`)) + releaseReach.reach(`cleanup-during-reset`) await collection.cleanup() activeRuns = 0 check(`run ${index} cleanup`) @@ -2402,6 +2472,17 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { ]) } +// Replay the smallest incomplete deliveries and their terminal neighbors in +// every mode through the same driver and cleanup comparison as generation. +fixedCase.each([`eager`, `on-demand`, `progressive`] as const)( + `releases listeners after incomplete delivery in %s mode`, + async (mode) => { + for (const answer of [`row`, `reset`] as const) + for (const end of [`open`, `give-up`, `abort`] as const) + await checkSourceRelease({ mode, runs: [{ answers: [answer], end }] }) + }, +) + const releaseViolation = sameViolation() const releaseProperty = fc.asyncProperty( fc.record({ @@ -2413,7 +2494,12 @@ const releaseProperty = fc.asyncProperty( runs: fc.array( fc.record({ answers: fc.array( - fc.constantFrom(`up-to-date` as const, `retry` as const), + fc.constantFrom( + `row` as const, + `up-to-date` as const, + `reset` as const, + `retry` as const, + ), { maxLength: 3 }, ), end: fc.constantFrom( From c73f47984c54c8625f9614f698a7b98b1d354e20 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 13:15:06 -0600 Subject: [PATCH 05/12] test(electric): enforce listener identity and pending-decision cleanup Track visibility listeners by ordered callback/capture registrations and calibrate the recorder against native dispatch over 8,421 bounded histories. Generate cleanup during held async error decisions, including partial/reset neighbors and subsequent runs, without changing production behavior. Law: completed sync cleanup releases adapter-owned resources (CleanupFn and creator guide); EventTarget identity includes callback and capture. Reach: pending-decision cleanup reached 18 fixed and 21 random histories; all eight declared states reached in both normal 40-history campaigns. RED: native-dispatch calibration fails old recorder; old campaigns accept wrong-capture SDK removal and conditional pending-decision release. GREEN: Electric package 1,131 tests pass, one skipped, no type errors; 8,421 calibration histories, lint and formatting pass. Variant: final oracle rejects wrong-capture SDK removal and four incorrect release conditions at cleanup assertions; direct replay pairs fail incorrect code and pass unchanged production. Limits: installed SDK/jsdom/controlled HTTP and bounded capture histories; no actual native-host or whole-version-range claim. Retired-session disposal deadline during rotation remains an explicit policy question, not a new rule. --- docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/2026-10-26-pr-2257.md | 161 ++++++++++++++++ ...ctric-sdk-delivery-oracle.property.test.ts | 174 ++++++++++++++++-- 3 files changed, 321 insertions(+), 16 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index d0a21780d4..cdbfc00c08 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -636,7 +636,7 @@ generic visitor behavior outside the named collectors. | QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, `packages/db/tests/transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md) | A finite array/Set model checks the compiled output bag for a joined DISTINCT subquery with an outer WHERE. Live-query drivers check selected containers, no-select, functional, and predicate-literal user objects with IR-like fields, top-level and aggregate-subquery proxy-shaped fields, arrays of selected references, outer virtual-field filters on joined DISTINCT and nested aggregate QueryRefs, and a renamed no-select source. The transaction suite documents the existing development-browser duplicate-load guard. The bounded cases run in `test:oracles` or the full DB suite. Joined DISTINCT, nested and flat aggregate source-update histories pass. Ordered joined `findOne()` checks initial, singleton, empty, restored, and changed-join-key cuts; an unordered `findOne()` QueryRef on either side of a join checks default-key candidate deletion and reinsertion. A materialized joined `findOne()` supplies a separate receiving formulation. Other `singleResult` forms, join forms, schedules, and cross-copy value handling outside the duplicate-load guard remain unproved. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | -| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, or aborting the external signal before cleanup, with restarts through `preload()`. It declares separate cleanup reach for partial delivery and reset before a later up-to-date. The minimal partial/reset histories cross each terminal outcome in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the managed cache rotation case receives the law for a retired session. It observes the browser listener in jsdom, not React Native's AppState adapter. | +| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the managed cache rotation case receives the law for a retired session. It observes the browser listener in jsdom, not React Native's AppState adapter. | | PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. A metadata-bearing `Collection.update` passes through an observed SDK watcher callback to the public and SQLite rows and an exact queued CRUD patch. Held callback ordering and synthetic zero-field cases remain controlled-only, and remote upload has no receiving fixture here. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The Browser coordinator RPC owner's per-election cells also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md index 5d07d44f34..9d00c1f220 100644 --- a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -348,3 +348,164 @@ AppState execution claim. There is no known reachable counterexample inside this bounded repair claim; sampled campaigns are not a universal proof. Whole-PR merge readiness is **not assessed** by this review evaluation; remote CI and later PR changes remain separate from the local checks above. + + +## Second review: enforcement repairs — 2026-10-26 + +Starting head: `1a5ca0edeacf0600f6f6a0d04df32ea90b7a4bea`. +Tested oracle SHA-256: +`5917bd5b125d71d952804aca26e513db99c75e7d46a8efb458cb91a2d563b543`. +The commit containing this entry identifies the repair tree. Production is +unchanged from the starting head. Four fresh reviewers independently examined +authority, histories, recorder behavior, and receiving paths. Raw reports and +the append-only evaluation ledger remain task-local; this entry preserves the +verdict-critical evidence. + +### Law, experiment, inference, enforcement + +Completed cleanup already releases all resources owned by the ended sync run +(`CleanupFn`, `packages/db/src/types.ts:419-429`; creator guide, Sync +Implementation). This deadline includes supported asynchronous error decisions +that have not settled. It does not require disposal on external abort alone, +zero listeners synchronously at cleanup entry, or any minimum listener count +while active. The active upper bound and one-add-per-start control are now +explicitly identified as installed-provider/driver premises. + +| Item | Evidence before repair | Repair and resulting evidence | +| --- | --- | --- | +| R01: removal attempts mistaken for actual listener release | An SDK 1.5.15 variant removes with capture=true after registering capture=false. The previous release campaigns pass, but actual EventTarget dispatch invokes the retained handler. Five of seven focused identity/order traces disagree with the old recorder. | Recorder replays add/remove calls in global invocation order, keyed by callback and normalized capture; duplicate adds coalesce. Native dispatch before fixture teardown independently calibrates every length 0..3 history over two callbacks, add/remove, and five capture spellings: 8,421 histories. The original SDK variant now fails the cleanup comparison. | +| H01: pending async error decision absent from release histories | A conditional release that skips `unsubscribeAll()` only while a user decision remains pending passes all 46original runtime tests. A held-decision probe rejects it after cleanup, while current production passes all 6mode/future-outcome cells. | Generate held retry/give-up endings, observe entry into real installed-SDK `onError`, keep each promise unresolved through cleanup and later runs, and resolve only in teardown. Declare pending-decision cleanup reach. Both campaigns reject the conditional design; direct replays fail it and pass unchanged production. | + +The expanded grammar retains row-only, up-to-date, reset, and retried-error +prefixes; all three modes; one to three runs; and the previous open, give-up, +and abort endings. Its fixed partial/reset examples now cross all five +endings, 30 histories. Expected resource ownership remains the independent active +run count. No production branch or internal retry/reset classifier computes +that result. + +### RED, GREEN, reach, and replay + +Recorder calibration was added before repairing its observer. On the old +recorder it fails a legal remove/remove/add history: recorded 0, actual dispatch 1. +The identical calibration passes after repair, including capture mismatch, +remove-before-add, re-add, duplicate-add, callback identity, dual capture, and +equivalent capture spellings. Existing final-release/primary-error calibration +still passes. Comparison remains before recorder.restore. + +Unchanged PR production passes the expanded fixed/random property. Each +campaign uses the same generator, observer, comparison and 40-history budget. +A private copy printed existing reach counters without changing semantics: + +| Declared reach | Fixed 20261024 | Random | +| --- | ---: | ---: | +| cleanup-while-loading | 21 | 22 | +| cleanup-after-ready | 13 | 10 | +| cleanup-after-give-up | 13 | 15 | +| cleanup-after-abort | 14 | 11 | +| cleanup-of-restarted-run | 27 | 24 | +| cleanup-after-partial-delivery | 6 | 17 | +| cleanup-during-reset | 11 | 17 | +| cleanup-during-retry-decision | 18 | 21 | + +Two supplementary 40-history random campaigns pass all reaches, with pending +reach 28 and 23. They supplement the normal pair and do not replace it. + +All four incorrect release designs fail both expanded campaigns at the +intended post-cleanup listener assertion (actual 1, allowed 0), not setup or a +timeout: + +| Incorrect release design | Fixed seed/path | Random seed/path | +| --- | --- | --- | +| Release only after user error decision settles | `20261024 / 1:1:2` | `-93353169 / 3:0:2:2:3` | +| Omit whole-stream release | `20261024 / 0:0:0:0` | `1599666023 / 0:0:0` | +| Release only without a pending source transaction | `20261024 / 5:0:1:1:1:1` | `-795275110 / 1:0:0:1:0:1:1` | +| Release only outside source reset | `20261024 / 5:0:1:2:2` | `-1735334928 / 0:0:1:4:3` | + +Three captured pending-decision replays, including the latest random path +above and `778119691 / 2:0:0`, fail the incorrect design and pass unchanged +production. Each selects exactly one replay test. The latest random witness +fails cleanup of a restarted run. The wrong-capture SDK variant also fails +with the final oracle at seed 20261024/path 0; the unmodified SDK passes that +same replay. Experimental production and SDK changes remain isolated from +the branch. + +Commands from repository root: + +```sh +pnpm --dir packages/electric-db-collection exec vitest run --coverage.enabled=false +pnpm lint:changed +pnpm exec prettier --check packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts docs/contributing/oracle-coverage.md docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +``` + +Package result: 1,131 runtime tests pass, one skipped, no type errors. Changed-file +lint and formatting pass. The new bounded calibration is one additional test; +production bytes and behavior are unchanged. No changeset is needed for this +test-only follow-up. + +### Authority clarification and remaining decision + +The completed-run cleanup law above already has authority. Partial rows, +reset, and pending error decisions introduce no new exception or product rule. +The one-listener active bound and exact construction count are pinned SDK +premises, not universal API requirements. + +Same-run cache rotation is a separate timing question. The public restart hook +requires immediate old-callback retirement and replacement after cache rotation +(`types.ts:435-443`; SQLite guide, managed generations). It does not explicitly +state that every old host resource must be released before replacement. Current +Electric source already awaits session cleanup before replacement, while the +existing rotation assertion runs later, after replacement demands settle and a +late old response. That assertion does not establish the earlier deadline. + +Candidate contract: release all retired provider-session resources before +starting its replacement. Rival: permit inert resources until enclosing-run +cleanup, while fencing old rows/acknowledgements. The recommendation is the +candidate, to prevent accumulation over repeated rotations. A user decision +was requested; no new deadline or earlier expectation was silently adopted. +The two independent enforcement repairs above do not depend on that decision. + +### Review evaluation, requirement outcomes, and limits + +The 41 source-order items remain accounted for: 27 accepted-design, 2 fixed-now +(R01/H01),2 already-fixed, 8 duplicates, 1 refuted, 1 design-decision; none deferred. +Each retains its original claim, evidence and durable destination in the local +ledger. The previous review-only stopping interpretation was superseded after +re-reading evaluate-review's explicit authorization to repair enforcement gaps. +All four reviewers merit hire for their bounded tasks: concrete observer and +history counterexamples, careful authority distinctions, and receiving-path +checks with honest environment limits. A separate source rereview of the final +repair found no further supported issue; runtime results above supply its +remaining execution evidence. + +ORC-001/002/003: existing resource authority and independent active-run result +preserved, with provider calibration distinguished. ORC-004: new pending endings +reconstruct the reported trace and partial/reset/restart neighbors; removing +that ending loses the demonstrated distinction. Recorder enumeration spans all +stated operation/callback/capture sequences; removing an axis loses the named +identity/normalization witness, and length 3 reaches re-add. ORC-005: real +installed SDK receives 503 and enters onError before cleanup; native dispatch +checks actual listener ownership before teardown. No missing source-information +hypothesis is needed to release known owned resources. ORC-006/007: old-observer +RED, final GREEN, incorrect-design assertions and direct fixed/random replays +recorded above. ORC-008/009: semantic model unchanged; held decisions are +authored driver promises, not copied adapter state. ORC-010: primary failure, +secondary diagnostics and independent final fixture release remain intact. +ORC-011: native dispatch supplies a different ownership formulation for the +named shared capture/order fault. ORC-012/013: exact-source record, distinguishing +pending/capture witnesses, and remaining rotation decision recorded here. +ORC-014: installed SDK supplies error-decision and listener behavior; HTTP and +jsdom supply explicitly controlled premises. ORC-015: all 8 declared states reached +in each normal campaign; pending-decision reach is based on actual callback +entry, not merely an authored ending token. Bounded recorder enumeration is +not an important generated property and asserts its exact 8,421-history count. + +Claims remain bounded to installed SDK 1.5.15, controlled HTTP, browser document +in jsdom, the stated captures and passive callbacks, and these finite histories. +Once/signal automatic removal, mutable/exotic option objects, arbitrary callback +mutation during dispatch, actual native hosts, every accepted SDK version, +arbitrary streaming/acceptance schedules, and post-decision effects after +fixture teardown are not established. Separate two-Collection and exact +SDK 1.5.23-source controlled AppState probes pass current production and reject +the base missing-release path, but do not claim checked-in generated peer +isolation or actual iOS/Android execution. No current production defect was +found in these paths. Whole-PR readiness is not assessed by this evaluation. diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts index c71c9c099c..f4ca87d213 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts @@ -312,7 +312,10 @@ function controlledHttp() { } } -// Records the `visibilitychange` listeners that code registers on `document`. +// EventTarget registration identity is callback plus capture. Replay native +// add/remove calls in order: a removal cannot erase a later registration, and +// repeated additions of one identity retain one listener. The installed SDK +// uses no once or signal options, which would need automatic-removal tracking. function recordVisibilityListeners() { const add = vi.spyOn(document, `addEventListener`) const remove = vi.spyOn(document, `removeEventListener`) @@ -323,10 +326,39 @@ function recordVisibilityListeners() { return { registered: () => visibility(add.mock.calls).length, live: () => { - const removed = new Set(visibility(remove.mock.calls)) - return visibility(add.mock.calls).filter( - (listener) => !removed.has(listener), - ).length + const calls = [ + ...add.mock.calls.map((call, index) => ({ + call, + adding: true, + order: add.mock.invocationCallOrder[index]!, + })), + ...remove.mock.calls.map((call, index) => ({ + call, + adding: false, + order: remove.mock.invocationCallOrder[index]!, + })), + ].sort((left, right) => left.order - right.order) + const listeners = new Map< + EventListenerOrEventListenerObject, + Set + >() + for (const { + call: [type, listener, options], + adding, + } of calls) { + if (type !== `visibilitychange` || listener === null) continue + const capture = + typeof options === `boolean` ? options : Boolean(options?.capture) + if (adding) { + const captures = listeners.get(listener) ?? new Set() + captures.add(capture) + listeners.set(listener, captures) + } else listeners.get(listener)?.delete(capture) + } + return [...listeners.values()].reduce( + (count, captures) => count + captures.size, + 0, + ) }, restore: () => { // The comparison has already run. Release even a failed subject's @@ -343,6 +375,81 @@ function recordVisibilityListeners() { } } +// EventTarget identifies a listener by callback and capture, coalesces duplicate +// additions, and applies removals in order (DOM Standard, "EventTarget"). This +// bounded calibration sends every sequence of up to three add/remove calls +// through the real EventTarget, then compares dispatch with the recorder before +// teardown. Two callbacks and both capture modes distinguish wrong identities; +// omitted, boolean, and object options distinguish equivalent capture spellings. +// Three calls reach add/remove/re-add. Once and signal options are outside this +// recorder's installed-SDK domain. +fixedCase( + `records EventTarget ownership across bounded listener histories`, + () => { + type Step = { + add: boolean + callback: number + options: boolean | { capture: boolean } | undefined + } + const options = [ + undefined, + false, + true, + { capture: false }, + { capture: true }, + ] + const steps: Array = [false, true].flatMap((add) => + [0, 1].flatMap((callback) => + options.map((capture) => ({ add, callback, options: capture })), + ), + ) + function* histories(prefix: Array = []): Generator> { + yield prefix + if (prefix.length < 3) + for (const step of steps) yield* histories([...prefix, step]) + } + let checked = 0 + for (const history of histories()) { + const listeners = recordVisibilityListeners() + const callbacks = [vi.fn(), vi.fn()] + try { + for (const step of history) { + const callback = callbacks[step.callback]! + if (step.add) + document.addEventListener( + `visibilitychange`, + callback, + step.options, + ) + else + document.removeEventListener( + `visibilitychange`, + callback, + step.options, + ) + } + document.dispatchEvent(new Event(`visibilitychange`)) + // Native dispatch supplies the independent count, including both capture + // identities of one callback. Teardown cannot change this observation. + const dispatched = callbacks.reduce( + (count, callback) => count + callback.mock.calls.length, + 0, + ) + expect(listeners.live(), JSON.stringify(history)).toBe(dispatched) + checked++ + } finally { + listeners.restore() + for (const callback of callbacks) { + document.removeEventListener(`visibilitychange`, callback, false) + document.removeEventListener(`visibilitychange`, callback, true) + } + } + } + expect(checked).toBe(8421) + }, + 60_000, +) + // Teardown must preserve a failed comparison and release its registrations. // Enumerate every retained subset of three fresh callbacks. Dispatch observes // actual EventTarget ownership independently of the recorder's live count. @@ -2272,9 +2379,13 @@ fixedCase.each(clientRetryCells)( * zero to three requests: a row-only batch, a row with an up-to-date, an HTTP * 409 reset, or a 503 that `onError` retries. Rows and resets may remain * incomplete when cleanup starts; neither needs a later up-to-date first. - * The run then stays open, gives up on one more 503 - * because `onError` returns no retry, or aborts the external signal. Cleanup - * ends the run. The signal stays aborted, so a run that starts after an abort + * The run then stays open, gives up on one more 503, aborts the external + * signal, or receives a 503 whose asynchronous `onError` decision stays + * pending through cleanup. That held decision can later retry or give up; + * neither future outcome may postpone the run's resource release. Held + * decisions settle only in fixture teardown, so later runs can start while + * an old decision is still pending. Cleanup ends the run. The signal stays + * aborted, so a run that starts after an abort * starts with an aborted signal and sends no request. The provider answers * only a request the client sent. The grammar does not answer a request after * a give-up or an abort, because the client sends none. @@ -2283,9 +2394,12 @@ fixedCase.each(clientRetryCells)( * installed ShapeStream, and controlled HTTP. The recorder wraps * `document.addEventListener` and `document.removeEventListener` and counts * the `visibilitychange` listeners still registered. The comparison runs after - * each provider answer, give-up, abort, and cleanup, once the client has sent - * its next request or decided to send none. A calibration check at the end - * shows that the recorder saw one listener for each run. + * each ordinary provider answer, give-up, abort, and cleanup. Ordinary answers + * wait for the next request; a held decision instead positively observes + * `onError` entry before cleanup, without awaiting that decision. The model + * still requires zero listeners after completed cleanup. The active upper + * bound and one-registration-per-run calibration describe this installed SDK + * and the driver's one-stream topology, rather than a universal SDK policy. * * Limits: the lane observes the browser listener in jsdom. Client versions * that observe React Native's AppState instead register through a runtime @@ -2296,7 +2410,7 @@ fixedCase.each(clientRetryCells)( */ type ReleaseRun = { answers: Array<`row` | `up-to-date` | `reset` | `retry`> - end: `open` | `give-up` | `abort` + end: `open` | `give-up` | `abort` | `deciding-retry` | `deciding-give-up` } type ReleaseHistory = { mode: `eager` | `on-demand` | `progressive` @@ -2310,6 +2424,7 @@ type ReleaseState = | `cleanup-of-restarted-run` | `cleanup-after-partial-delivery` | `cleanup-during-reset` + | `cleanup-during-retry-decision` const createReleaseReach = () => createOracleReach(`electric.sdk-source-release`, [ @@ -2320,6 +2435,7 @@ const createReleaseReach = () => `cleanup-of-restarted-run`, `cleanup-after-partial-delivery`, `cleanup-during-reset`, + `cleanup-during-retry-decision`, ]) // Each campaign declares its own reach. let releaseReach = createReleaseReach() @@ -2331,6 +2447,9 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { const external = new AbortController() let retries = true let onErrorCalls = 0 + let heldDecision: + ReturnType | void>> | undefined + const settleDecisions: Array<() => void> = [] const collection = createCollection( electricCollectionOptions({ id: `sdk-release-${++sequence}`, @@ -2347,7 +2466,7 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { }, onError: () => { onErrorCalls++ - return retries ? {} : undefined + return heldDecision?.promise ?? (retries ? {} : undefined) }, }, syncMode: history.mode, @@ -2429,7 +2548,21 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { request = await next(`run ${index} ${answer}`) check(`run ${index} ${answer}`) } - if (request && run.end === `give-up`) { + if ( + request && + (run.end === `deciding-retry` || run.end === `deciding-give-up`) + ) { + const calls = onErrorCalls + const decision = deferred | void>() + heldDecision = decision + settleDecisions.push(() => + decision.resolve(run.end === `deciding-retry` ? {} : undefined), + ) + request.respond(new Response(`transient`, { status: 503 })) + await vi.waitFor(() => expect(onErrorCalls).toBe(calls + 1)) + releaseReach.reach(`cleanup-during-retry-decision`) + check(`run ${index} pending decision`) + } else if (request && run.end === `give-up`) { const calls = onErrorCalls retries = false request.respond(new Response(`transient`, { status: 503 })) @@ -2460,11 +2593,14 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { await collection.cleanup() activeRuns = 0 check(`run ${index} cleanup`) + // A later run has its own decision; the old promise remains unresolved. + heldDecision = undefined } // Calibration: the recorder observed the listener of every run's stream. expect(listeners.registered()).toBe(runsStarted) }, [ () => collection.cleanup(), + () => settleDecisions.forEach((settle) => settle()), () => http.close(), () => listeners.restore(), () => consoleError.mockRestore(), @@ -2478,7 +2614,13 @@ fixedCase.each([`eager`, `on-demand`, `progressive`] as const)( `releases listeners after incomplete delivery in %s mode`, async (mode) => { for (const answer of [`row`, `reset`] as const) - for (const end of [`open`, `give-up`, `abort`] as const) + for (const end of [ + `open`, + `give-up`, + `abort`, + `deciding-retry`, + `deciding-give-up`, + ] as const) await checkSourceRelease({ mode, runs: [{ answers: [answer], end }] }) }, ) @@ -2506,6 +2648,8 @@ const releaseProperty = fc.asyncProperty( `open` as const, `give-up` as const, `abort` as const, + `deciding-retry` as const, + `deciding-give-up` as const, ), }), { minLength: 1, maxLength: 3 }, From 04b1c4b2299a95746c6f64521d54ca4b2804a194 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 13:35:20 -0600 Subject: [PATCH 06/12] test(electric): check rotation release before replacement State the approved Electric resource-release deadline and observe the real SDK constructor before replacement listeners can register. Generate 1-3 sequential rotations and three retired-response timings. Check both retained demands stay pending until replacement source responses; preserve assertion-time values. Law: user-approved release before replacement construction; existing Electric scoped-recovery promise completes each demand after its source snapshot applies. Reach: six rotation states reached in both 40-history campaigns, including retirement of replacement sessions and demands awaiting replacement. RED: earlier late comparisons accepted after-constructor disposal and retired demand early fulfillment. Final comparisons reject both; initial-only disposal also fails the second rotation. Diagnostic comparison payload drift reproduced. GREEN: Electric 1133 runtime tests pass, one skipped, no type errors; final owner 49 runtime + 3 type tests pass; core replay/manifest 38 tests pass. Variant: exact replay failure/pass pairs; legal disposal after cache rotation but before construction passes both campaigns. Captured values survive cleanup. Limits: installed browser SDK/jsdom, controlled HTTP and Map-backed cache, sequential 1-3 rotations; no native, overlapping, full SDK range or every private resource claim. Production code is unchanged. See docs/contributing/oracle-reviews/2026-10-26-pr-2257.md for evidence. --- docs/collections/electric-collection.md | 7 + docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/2026-10-26-pr-2257.md | 139 +++++ packages/db/tests/oracle-config.ts | 1 + packages/db/tests/oracle-replay-manifest.ts | 2 +- ...ctric-sdk-delivery-oracle.property.test.ts | 515 ++++++++++++------ 6 files changed, 490 insertions(+), 176 deletions(-) diff --git a/docs/collections/electric-collection.md b/docs/collections/electric-collection.md index 0b56f39712..806d3d8546 100644 --- a/docs/collections/electric-collection.md +++ b/docs/collections/electric-collection.md @@ -355,6 +355,13 @@ A mutation handler waiting on one of them rejects; a later handler can wait for evidence from the replacement session. The replacement reloads demanded subsets and does not treat acknowledgements from the retired session as current. +Before constructing a replacement `ShapeStream`, Electric releases the +adapter-owned resources of the retired provider session, including its +subscriptions and host lifecycle listeners. The replacement starts after cache +rotation completes. An HTTP request that ignores cancellation may still finish +later; its response cannot publish rows or settle waits for the replacement +session. + Persisted resumes wait for the cached row baseline to finish hydrating. If the persistence wrapper cannot verify hydration completion, Electric starts a fresh snapshot instead of using the saved offset and handle. It warns once per options diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index cdbfc00c08..63fa0fa4cb 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -636,7 +636,7 @@ generic visitor behavior outside the named collectors. | QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, `packages/db/tests/transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md) | A finite array/Set model checks the compiled output bag for a joined DISTINCT subquery with an outer WHERE. Live-query drivers check selected containers, no-select, functional, and predicate-literal user objects with IR-like fields, top-level and aggregate-subquery proxy-shaped fields, arrays of selected references, outer virtual-field filters on joined DISTINCT and nested aggregate QueryRefs, and a renamed no-select source. The transaction suite documents the existing development-browser duplicate-load guard. The bounded cases run in `test:oracles` or the full DB suite. Joined DISTINCT, nested and flat aggregate source-update histories pass. Ordered joined `findOne()` checks initial, singleton, empty, restored, and changed-join-key cuts; an unordered `findOne()` QueryRef on either side of a join checks default-key candidate deletion and reinsertion. A materialized joined `findOne()` supplies a separate receiving formulation. Other `singleResult` forms, join forms, schedules, and cross-copy value handling outside the duplicate-load guard remain unproved. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | -| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the managed cache rotation case receives the law for a retired session. It observes the browser listener in jsdom, not React Native's AppState adapter. | +| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/durable rows, late old-response rejection, and final cleanup comparisons remain. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated lane. | | PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. A metadata-bearing `Collection.update` passes through an observed SDK watcher callback to the public and SQLite rows and an exact queued CRUD patch. Held callback ordering and synthetic zero-field cases remain controlled-only, and remote upload has no receiving fixture here. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The Browser coordinator RPC owner's per-election cells also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md index 9d00c1f220..7e6851fcff 100644 --- a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -509,3 +509,142 @@ SDK 1.5.23-source controlled AppState probes pass current production and reject the base missing-release path, but do not claim checked-in generated peer isolation or actual iOS/Android execution. No current production defect was found in these paths. Whole-PR readiness is not assessed by this evaluation. + +## Approved rotation deadline and final rereview — 2026-10-26 + +Parent commit: `c73f47984c54c8625f9614f698a7b98b1d354e20`. +Final SDK-delivery owner SHA256: +`f046cb9cbdf26a8ffc8c746bf762f00d949faf054bc6cbe7e4e5545fd7d11b1f`. +Unchanged Electric production SHA256: +`4919ad548c416952fe51822b9e1d06c20dd5807b6dda40909bc3ed7046b66015`. +These source identities cover the executable change accompanying this entry. + +### Law decision and current production + +The user explicitly selected “Yes — require release before replacement.” A03 +is now implemented in the Electric guide: release the retired provider +session's adapter-owned resources before constructing its replacement +`ShapeStream`. Callback retirement remains immediate; replacement follows +cache-rotation completion. An HTTP request that ignores cancellation may +finish later without gaining authority over replacement rows or waits. + +This is the new explicit deadline that the earlier contract left ambiguous. +Current Electric already satisfies it; no production edit was needed. The +shared restart hook is unchanged: Query recovery reuses observers and shared +resources rather than constructing a replacement provider session. + +| Law | Authority and relation to production | +| --- | --- | +| Completed sync cleanup releases owned resources, including during partial delivery, reset, and a pending error decision. | Existing `CleanupFn` and creator guide. Base production violated this; the PR's unconditional stream release satisfies the measured paths. R01/H01 strengthen observation and reach, without changing this law. | +| Retired Electric session resources are released before replacement construction. | Newly approved A03 deadline, now in the Electric guide. Current production already awaits private cleanup before constructing a replacement. The earlier settled-rotation comparison did not prove this ordering. | +| Each retained subset demand completes after its replacement snapshot applies. | Existing Electric scoped-recovery guide and `LoadSubsetFn`. Current production passes; final rows alone had allowed a plausible early-settlement implementation. | + +### Experiments and repairs + +The existing real-SDK rotation driver now generates one to three sequential +rotations in the same Collection sync run. A retired snapshot arrives during +held rotation, before replacement snapshots, or after replacement settlement. +Fresh IDs and demand unloads preserve an actual pending old snapshot in every +round. A forwarding Proxy records document-listener ownership immediately +before invoking the real installed SDK constructor. It neither disposes +resources nor substitutes SDK behavior. The model requires zero old listeners +at that boundary, one replacement listener after delivery, and zero after +completed Collection cleanup. + +The following experiments distinguish that deadline and preserve its freedom: + +- Disposal immediately after replacement construction fails the new comparison: + expected zero, actual one. Removing only that comparison lets both 40-history + campaigns pass, including all later row, demand, and listener comparisons. +- Disposal after cache rotation fulfills but immediately before construction + passes both campaigns. A diagnostic replay observes one listener while + rotation is held and zero before construction. The oracle therefore does + not require synchronous disposal at rotation entry. +- Disposal only for the initial session fails when a generated second rotation + retires its replacement. A one-rotation example alone would miss this rule. +- Exact final-owner replay pairs use property `electric.sdk-rotation-release`, + seed `20261026`, and paths `0:0:0` and `0:1:1`, respectively. The two incorrect + variants fail at the constructor comparison; unchanged production passes + both. These are assertion failures, not setup failures or timeouts. + +Independent rereview found two additional oracle issues. Both are repaired: + +| ID | Original claim and decisive evidence | Repair and disposition | +| --- | --- | --- | +| S01 | A retired demand can start correct replacement work in the background and report success before its source response. The earlier owner accepted this variant in its example and both generated campaigns. A private public-promise probe observed early fulfillment. | Track both demands and compare pending outcomes while rotation is held and before the first replacement response; require both fulfilled afterward. The same generated histories now reject the variant with `[fulfilled, pending]` versus `[pending, pending]`. Final seed `20261026`, path `0:0:0` fails the variant and passes baseline. `fixed-now`. | +| S02 | Cleanup mutated the settlement array retained by an assertion: the message described `[fulfilled, pending]`, but the later Received diff showed both fulfilled. This lost diagnostic evidence while preserving the failed checkpoint. | Copy primitive settlement/construction arrays at comparison time. The final fixed-example failure preserves `[fulfilled, pending]` in both message and Received after cleanup. `fixed-now`, under ORC-010. | + +S01's provisional reviewer label “R03” is an alias only; the original ledger's +R03 primary-error item retains its identity. A broad variant that returns early +for every load fails the held-rotation pending comparison. The more selective +retired-demand variant fails the later pre-response comparison, so both +neighboring regimes remain distinguishable. + +### Reach, GREEN, and review requirements + +Both normal rotation campaigns run 40 histories with the same grammar, +observations, comparison, and budget; only seed selection differs. The property +is registered in both the oracle registry and named replay manifest. Final-source +diagnostics print counts without changing expectations or driver behavior: + +| Rotation state | Fixed seed 20261026 | Unseeded | +| --- | ---: | ---: | +| Old response during held rotation | 19 | 23 | +| Old response before replacement snapshots | 25 | 18 | +| Old response after replacement settlement | 17 | 22 | +| Initial-session retirement | 40 | 40 | +| Replacement-session retirement | 26 | 28 | +| Both demands await replacement responses | 40 | 40 | + +All eight source-release states also reach their minimum on this final source. +Their fixed/random counts, in declaration order, are +`21/22, 13/10, 13/15, 14/15, 27/31, 6/14, 11/21, 18/16`. + +Validation: + +- Full Electric package after S01: 1,133 runtime tests pass, one skipped, no + type errors. After S02's diagnostic snapshots, the complete changed owner + passes all 49 runtime and three type tests, including both generated laws. +- Core replay/manifest suite: all 38 tests pass, no type errors. +- Changed-file lint, Prettier, and whitespace checks pass. +- R01 wrong-capture and H01 pending-decision release replay pairs were also + rerun after introducing the constructor wrapper: each incorrect design fails + the cleanup assertion; its unchanged-production partner passes. + +ORC-001/002/003: approved deadline and existing subset law are explicit beside +independent zero-listener and pending-demand rules. ORC-004: length 1 reconstructs +the original case; lengths 2–3 distinguish replacement retirement; three delivery +cuts preserve legal late-response timings. Removing the earlier observation +admits the measured wrong ordering. The grammar answers admitted requests and +does not construct replacement before releasing the cache result. ORC-005/006: +real constructor/HTTP path reached; ordering and settlement variants fail their +intended comparisons. ORC-007: equal fixed/random budgets and exact direct +replays pass/fail as stated. ORC-008/009: no copied production state machine; +session/run and physical work/demand settlement remain distinct. ORC-010: +release the held gate before cleanup, retain primary/secondary diagnostics, +release fixture resources, and snapshot primitive observations. ORC-011: no +new shared semantic fault needing a second resource model was identified; +R01's native-dispatch formulation remains. ORC-012/013: this source-bound record, +legal-before/incorrect-after neighbor, and initial/replacement distinction +supply review and boundary evidence. ORC-014: installed SDK supplies real +construction, parsing, listener behavior, and promise delivery; HTTP and the +Map-backed cache supply explicitly controlled premises. ORC-015: all six new +states and eight existing release states are measured and required. + +### Final accounting and limits + +All original raw reports and the two new sibling findings were revisited. +Final totals: **43 = 27 accepted-design + 5 fixed-now + 2 already-fixed + +8 duplicates + 1 refuted**. There are no confirmed-open, deferred, or pending +design-decision items. A03 moved from design-decision to fixed-now after the +explicit approval and implementation. Independent rereview found no further +supported issue; the reviewers merit hire for concrete distinguishing examples, +authority discipline, and checking their own diagnostic limitations. + +This closes the named enforcement gaps within installed SDK 1.5.15, jsdom, +controlled HTTP, Map-backed persistence, sequential one-to-three rotations, +and the stated comparison cuts. Overlapping rotations, actual native hosts, +every supported SDK version, arbitrary failure schedules, every future private +SDK resource, and separate mutation-evidence wait semantics are not established. +No production code changed. Whole-PR readiness remains unassessed by this +targeted evaluation; CI for earlier commits does not cover these changes. diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index eba93fa818..201588e470 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -41,6 +41,7 @@ const staticOracleProperties = [ `electric.sdk-snapshot-delivery`, `electric.sdk-dnf-membership`, `electric.sdk-source-release`, + `electric.sdk-rotation-release`, `electric-recovery.publication-stream-convergence`, `sqlite-resume.startup-generation`, `sqlite-resume.managed-claim-read`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 94f1e1b606..c19d806728 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -77,7 +77,7 @@ const ownerGroups: ReadonlyArray = [ [ `electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts`, `electric`, - `sdk-snapshot-delivery sdk-dnf-membership sdk-source-release`, + `sdk-snapshot-delivery sdk-dnf-membership sdk-source-release sdk-rotation-release`, ], [ `db/tests/collection-sync-reentrancy-oracle.test.ts`, diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts index f4ca87d213..29fc264ff1 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts @@ -50,6 +50,25 @@ import type { } from '../../db-sqlite-persistence-core/src' import type { Message } from '@electric-sql/client' +// Observe the real constructor boundary without changing the installed SDK's +// options, prototype, methods, or HTTP behavior. Only the rotation lane enables +// the callback; every construction still executes the original ShapeStream. +const sdkConstruction = vi.hoisted(() => ({ + observe: undefined as (() => void) | undefined, +})) +vi.mock(`@electric-sql/client`, async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + ShapeStream: new Proxy(original.ShapeStream, { + construct(target, args, newTarget) { + sdkConstruction.observe?.() + return Reflect.construct(target, args, newTarget) + }, + }), + } +}) + /** * # Does the installed Electric SDK deliver the adapter's assumed protocol? * @@ -1012,213 +1031,361 @@ fixedCase(`does not certify an aborted SDK subset snapshot`, async () => { * the installed ShapeStream and HTTP path. A Map-backed adapter supplies the * generation boundary; real SQLite and native hosts have separate owners. * It also receives the stream-listener release law below for the retired - * session: after the rotation only the replacement stream's listener stays - * registered, and none stays after cleanup. + * session. The Electric guide requires releasing its owned resources before + * constructing the replacement ShapeStream. The independent rule is zero + * old listeners at that constructor boundary, one replacement listener after + * delivery, and zero after cleanup. Cache rotation may finish before disposal; + * no zero-listener promise applies while the rotation result is held. + * + * Generated histories rotate one to three times in the same sync run. Each + * retired HTTP snapshot arrives while rotation is held, just before replacement + * snapshots, or after replacement settlement. A forwarding constructor observer + * records listener ownership before invoking the real SDK. It does not release + * resources or replace SDK behavior. This lane + * covers sequential rotations in an on-demand run with the installed browser + * SDK; overlapping rotations, other SDK versions, and native hosts remain separate. + * Both retained demands must remain pending until replacement source responses + * arrive. Promise outcomes are observed while rotation is held and again before + * the first replacement response; final rows alone cannot establish settlement. */ -fixedCase( - `discards an old SDK snapshot after managed cache rotation`, - async () => { - const listeners = recordVisibilityListeners() - const http = controlledHttp() - const rows = new Map>([ - [`old-generation`, new Map()], - ]) - const metadata = new Map>([ - [`old-generation`, new Map()], - ]) - let storageId = `old-generation` - let claimExpired = false - let rotations = 0 - const claimId = `managed-sdk-claim` - const adapter: PersistenceAdapter = { - claimCacheGeneration: () => - Promise.resolve({ - storageCollectionId: storageId, - claimId, - expiresAtMs: Date.now() + 10_000, - }), - renewCacheGenerationClaim: () => - Promise.resolve(claimExpired ? undefined : Date.now() + 10_000), - rotateCacheGeneration: ( - _collectionId, - _claimId, - resetMetadata, - expectedStorageId, - ) => { - expect(expectedStorageId).toBe(storageId) - rotations++ - storageId = `new-generation` - rows.set(storageId, new Map()) - metadata.set( - storageId, - new Map( - resetMetadata ? [[resetMetadata.key, resetMetadata.value]] : [], - ), - ) - claimExpired = false - return Promise.resolve({ - storageCollectionId: storageId, - claimId, - expiresAtMs: Date.now() + 10_000, - }) - }, - releaseCacheGenerationClaim: async () => {}, - loadSubset: (id) => - Promise.resolve( - Array.from(rows.get(storageTargetId(id)) ?? [], ([key, value]) => ({ +type RetiredSnapshotCut = + `during-rotation` | `before-snapshots` | `after-snapshots` +const createRotationReach = () => + createOracleReach< + | RetiredSnapshotCut + | `first-session` + | `replacement-session` + | `demands-await-replacement` + >(`electric.sdk-rotation-release`, [ + `during-rotation`, + `before-snapshots`, + `after-snapshots`, + `first-session`, + `replacement-session`, + `demands-await-replacement`, + ]) +let rotationReach: ReturnType | undefined + +async function checkManagedRotation(cuts: ReadonlyArray) { + rotationReach?.beginHistory() + const listeners = recordVisibilityListeners() + const http = controlledHttp() + const rows = new Map>([ + [`old-generation`, new Map()], + ]) + const metadata = new Map>([ + [`old-generation`, new Map()], + ]) + let storageId = `old-generation` + let claimExpired = false + let rotations = 0 + let allowRotation = deferred() + let rotationRequested = deferred() + let replacementConstructed = deferred() + const beforeConstruction: Array = [] + sdkConstruction.observe = () => { + beforeConstruction.push(listeners.live()) + replacementConstructed.resolve() + } + const claimId = `managed-sdk-claim` + const adapter: PersistenceAdapter = { + claimCacheGeneration: () => + Promise.resolve({ + storageCollectionId: storageId, + claimId, + expiresAtMs: Date.now() + 10_000, + }), + renewCacheGenerationClaim: () => + Promise.resolve(claimExpired ? undefined : Date.now() + 10_000), + rotateCacheGeneration: ( + _collectionId, + _claimId, + resetMetadata, + expectedStorageId, + ) => { + expect(expectedStorageId).toBe(storageId) + rotations++ + rotationRequested.resolve() + storageId = `generation-${rotations}` + rows.set(storageId, new Map()) + metadata.set( + storageId, + new Map( + resetMetadata ? [[resetMetadata.key, resetMetadata.value]] : [], + ), + ) + claimExpired = false + return allowRotation.promise.then(() => ({ + storageCollectionId: storageId, + claimId, + expiresAtMs: Date.now() + 10_000, + })) + }, + releaseCacheGenerationClaim: async () => {}, + loadSubset: (id) => + Promise.resolve( + Array.from(rows.get(storageTargetId(id)) ?? [], ([key, value]) => ({ + key, + value, + })), + ), + loadResumeSnapshot: (id) => + Promise.resolve({ + rows: Array.from( + rows.get(storageTargetId(id)) ?? [], + ([key, value]) => ({ key, value, - })), + }), ), - loadResumeSnapshot: (id) => - Promise.resolve({ - rows: Array.from( - rows.get(storageTargetId(id)) ?? [], - ([key, value]) => ({ - key, - value, - }), - ), - keySet: { - status: - storageTargetId(id) === `old-generation` - ? `consistent` - : `incompatible`, - }, - collectionMetadata: Array.from( - metadata.get(storageTargetId(id)) ?? [], - ([key, value]) => ({ key, value }), - ), - latestTerm: 0, - latestSeq: 0, - latestRowVersion: 0, - resetEpoch: 0, - }), - applyCommittedTx: (id, tx) => { - expect(tx.cacheGenerationClaimId).toBe(claimId) - const generationRows = rows.get(storageTargetId(id))! - if (tx.truncate) generationRows.clear() - for (const mutation of tx.mutations) { - if (mutation.type === `delete`) { - generationRows.delete(Number(mutation.key)) - } else { - generationRows.set(Number(mutation.key), mutation.value as Item) - } - } - const generationMetadata = metadata.get(storageTargetId(id))! - for (const mutation of tx.collectionMetadataMutations ?? []) { - if (mutation.type === `delete`) - generationMetadata.delete(mutation.key) - else generationMetadata.set(mutation.key, mutation.value) + keySet: { + status: + storageTargetId(id) === `old-generation` + ? `consistent` + : `incompatible`, + }, + collectionMetadata: Array.from( + metadata.get(storageTargetId(id)) ?? [], + ([key, value]) => ({ key, value }), + ), + latestTerm: 0, + latestSeq: 0, + latestRowVersion: 0, + resetEpoch: 0, + }), + applyCommittedTx: (id, tx) => { + expect(tx.cacheGenerationClaimId).toBe(claimId) + const generationRows = rows.get(storageTargetId(id))! + if (tx.truncate) generationRows.clear() + for (const mutation of tx.mutations) { + if (mutation.type === `delete`) { + generationRows.delete(Number(mutation.key)) + } else { + generationRows.set(Number(mutation.key), mutation.value as Item) } - return Promise.resolve() - }, - ensureIndex: async () => {}, - } - const electric = electricCollectionOptions({ - id: `sdk-managed-restart-${++sequence}`, - shapeOptions: { - url: `http://test-url/managed-restart-${sequence}`, - params: { table: `rows` }, - // A provider can finish an HTTP request after stream retirement. - fetchClient: (input, init) => - http.fetchClient(input, { ...init, signal: undefined }), + } + const generationMetadata = metadata.get(storageTargetId(id))! + for (const mutation of tx.collectionMetadataMutations ?? []) { + if (mutation.type === `delete`) generationMetadata.delete(mutation.key) + else generationMetadata.set(mutation.key, mutation.value) + } + return Promise.resolve() + }, + ensureIndex: async () => {}, + } + const electric = electricCollectionOptions({ + id: `sdk-managed-restart-${++sequence}`, + shapeOptions: { + url: `http://test-url/managed-restart-${sequence}`, + params: { table: `rows` }, + // A provider can finish an HTTP request after stream retirement. + fetchClient: (input, init) => + http.fetchClient(input, { ...init, signal: undefined }), + }, + syncMode: `on-demand`, + startSync: true, + getKey: (row) => row.id, + }) + const collection = await withElectricSetup( + () => + createCollection( + persistedCollectionOptions< + Item, + string | number, + never, + ElectricCollectionUtils + >({ + ...electric, + persistence: { adapter }, + }), + ), + [ + () => allowRotation.resolve(), + () => http.close(), + () => listeners.restore(), + () => { + sdkConstruction.observe = undefined }, - syncMode: `on-demand`, - startSync: true, - getKey: (row) => row.id, - }) - const collection = createCollection( - persistedCollectionOptions< - Item, - string | number, - never, - ElectricCollectionUtils - >({ - ...electric, - persistence: { adapter }, - }), + ], + ) + const demand = (id: number) => ({ + where: new IR.Func(`eq`, [new IR.PropRef([`id`]), new IR.Value(id)]), + }) + const respond = (request: Request, row: Item | undefined, offset: number) => + request.respond( + new Response( + JSON.stringify({ + metadata: { + xmin: `10`, + xmax: `20`, + xip_list: [], + database_lsn: `10`, + snapshot_mark: offset, + }, + data: row + ? [ + { + key: String(row.id), + value: { ...row, id: String(row.id) }, + headers: { operation: `insert` }, + }, + ] + : [], + }), + { headers: headers(offset) }, + ), ) - const demand = (id: number) => ({ - where: new IR.Func(`eq`, [new IR.PropRef([`id`]), new IR.Value(id)]), - }) - const respond = (request: Request, row: Item | undefined, offset: number) => - request.respond( - new Response( - JSON.stringify({ - metadata: { - xmin: `10`, - xmax: `20`, - xip_list: [], - database_lsn: `10`, - snapshot_mark: offset, - }, - data: row - ? [ - { - key: String(row.id), - value: { ...row, id: String(row.id) }, - headers: { operation: `insert` }, - }, - ] - : [], - }), - { headers: headers(offset) }, - ), - ) - await withElectricCleanup(async () => { + await withElectricCleanup(async () => { + for (const [round, retiredSnapshotCut] of cuts.entries()) { + allowRotation = deferred() + rotationRequested = deferred() + const oldId = round * 2 + 1 + const newId = oldId + 1 + const oldOptions = demand(oldId) + const newOptions = demand(newId) + const settlements: Array<`pending` | `fulfilled` | `rejected`> = [] + const loadDemand = (options: ReturnType) => { + const slot = settlements.push(`pending`) - 1 + const result = Promise.resolve(collection._sync.loadSubset(options)) + void result.then( + () => { + settlements[slot] = `fulfilled` + }, + () => { + settlements[slot] = `rejected` + }, + ) + return result + } + const assertDemandsPending = async (checkpoint: string) => { + // Requests are still held. Yield one task so nested promise reactions + // report an early settlement before comparing their public outcomes. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect([...settlements], checkpoint).toEqual([`pending`, `pending`]) + } await atCheckpoint(http.take(), `old managed stream`) - const oldDemand = Promise.resolve(collection._sync.loadSubset(demand(1))) - void oldDemand.catch(() => undefined) + const oldDemand = loadDemand(oldOptions) const oldSnapshot = await atCheckpoint( http.take(true), `old managed subset snapshot`, ) + replacementConstructed = deferred() claimExpired = true - const newDemand = Promise.resolve(collection._sync.loadSubset(demand(2))) - void newDemand.catch(() => undefined) - await vi.waitFor(() => expect(rotations).toBe(1)) - expect(storageId).toBe(`new-generation`) + const newDemand = loadDemand(newOptions) + await atCheckpoint(rotationRequested.promise, `managed rotation entry`) + expect(rotations).toBe(round + 1) + expect(storageId).toBe(`generation-${round + 1}`) + expect([...beforeConstruction]).toHaveLength(round + 1) + expect(listeners.registered()).toBe(round + 1) + + const deliverRetiredSnapshot = async () => { + respond(oldSnapshot, { id: oldId, name: `late-old` }, round * 4 + 1) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(collection.get(oldId)).toBeUndefined() + } + if (retiredSnapshotCut === `during-rotation`) + await deliverRetiredSnapshot() + await assertDemandsPending(`demands while cache rotation is held`) + allowRotation.resolve() + await atCheckpoint( + replacementConstructed.promise, + `replacement construction`, + ) + expect([...beforeConstruction]).toHaveLength(round + 2) + expect( + beforeConstruction[round + 1], + `retired listeners before replacement construction`, + ).toBe(0) + rotationReach?.reach(retiredSnapshotCut) + rotationReach?.reach( + round === 0 ? `first-session` : `replacement-session`, + ) + if (retiredSnapshotCut === `before-snapshots`) + await deliverRetiredSnapshot() + await assertDemandsPending(`demands before replacement source responses`) + rotationReach?.reach(`demands-await-replacement`) // The replacement SDK first reacquires the old demand, then the new one. - // Empty source snapshots for id 1 distinguish source authority from the - // old request's late row; the id 2 snapshot establishes the new row. + // Empty snapshots for oldId distinguish source authority from the old + // request's late row; the newId snapshot establishes the fresh row. respond( await atCheckpoint(http.take(true), `first replacement snapshot`), undefined, - 2, + round * 4 + 2, ) respond( await atCheckpoint(http.take(true), `replayed old demand`), undefined, - 3, + round * 4 + 3, ) respond( await atCheckpoint(http.take(true), `new demand snapshot`), - { id: 2, name: `fresh` }, - 4, + { id: newId, name: `fresh` }, + round * 4 + 4, ) await atCheckpoint( Promise.all([oldDemand, newDemand]), `replacement demand settlement`, ) - expect(collection.get(2)?.name).toBe(`fresh`) - - respond(oldSnapshot, { id: 1, name: `late-old` }, 1) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(collection.get(1)).toBeUndefined() - expect(collection.get(2)?.name).toBe(`fresh`) - expect([...rows.get(`new-generation`)!.keys()]).toEqual([2]) - expect(listeners.registered()).toBe(2) + expect([...settlements]).toEqual([`fulfilled`, `fulfilled`]) + expect(collection.get(newId)?.name).toBe(`fresh`) + + if (retiredSnapshotCut === `after-snapshots`) + await deliverRetiredSnapshot() + expect(collection.get(oldId)).toBeUndefined() + expect(collection.get(newId)?.name).toBe(`fresh`) + expect([...rows.get(storageId)!.keys()]).toEqual([newId]) + expect(listeners.registered()).toBe(round + 2) expect(listeners.live(), `live listeners after rotation`).toBe(1) - }, [ - () => collection.cleanup(), - () => http.close(), - () => expect(listeners.live(), `live listeners after cleanup`).toBe(0), - () => listeners.restore(), - ]) + // End these demands before the next round; its fresh IDs ensure an old + // source snapshot is really in flight when the next claim expires. + collection._sync.unloadSubset(oldOptions) + collection._sync.unloadSubset(newOptions) + } + }, [ + () => allowRotation.resolve(), + () => collection.cleanup(), + () => http.close(), + () => expect(listeners.live(), `live listeners after cleanup`).toBe(0), + () => listeners.restore(), + () => { + sdkConstruction.observe = undefined + }, + ]) +} + +fixedCase(`discards an old SDK snapshot after managed cache rotation`, () => + checkManagedRotation([`after-snapshots`]), +) +const rotationViolation = sameViolation() +sdkCampaigns( + `electric.sdk-rotation-release`, + `releases retired SDK resources before constructing a replacement`, + 20261026, + rotationViolation, + async (seed) => { + rotationReach = createRotationReach() + await fc.assert( + fc.asyncProperty( + fc.array( + fc.constantFrom( + `during-rotation`, + `before-snapshots`, + `after-snapshots`, + ), + { minLength: 1, maxLength: 3 }, + ), + (cuts) => + rotationViolation.check(cuts, () => checkManagedRotation(cuts)), + ), + { + ...oraclePropertyOptions(40, `electric.sdk-rotation-release`), + ...(seed === undefined ? {} : { seed }), + }, + ) + rotationReach.assertReached() }, ) From 997d21d7900bd6a9deed692942ea8d54bf2fdc6b Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 13:42:43 -0600 Subject: [PATCH 07/12] test(electric): satisfy package lint for oracle helpers --- .../oracle-reviews/2026-10-26-pr-2257.md | 16 ++++++++++++++++ ...electric-sdk-delivery-oracle.property.test.ts | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md index 7e6851fcff..af9eded76c 100644 --- a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -648,3 +648,19 @@ every supported SDK version, arbitrary failure schedules, every future private SDK resource, and separate mutation-evidence wait semantics are not established. No production code changed. Whole-PR readiness remains unassessed by this targeted evaluation; CI for earlier commits does not cover these changes. + +## Package ESLint follow-up — 2026-10-26 + +CI on `04b1c4b2299a95746c6f64521d54ca4b2804a194` exposed two package +ESLint errors that the narrower `lint:changed` oracle check did not report. +Use a type-only namespace import for the real SDK module type, and remove a +null comparison excluded by the typed listener-call domain. The installed SDK +and bounded calibration both supply non-null callbacks. The measured histories, +expected results, and checkpoints remain unchanged. + +Final owner SHA256: +`f6d2a06d3cab1e1c5b18a2c2b6285b0b8cadb4f805d679feefc45caadd519a66`. +Package ESLint passes for the changed Electric owner and core registry/manifest. +The complete owner passes all 49 runtime and three type tests, including the +8,421-history native calibration and both generated laws. This is a mechanical +follow-up to the evidence above; the 43-item review accounting is unchanged. diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts index 29fc264ff1..5ffb6f10c8 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts @@ -49,6 +49,7 @@ import type { TxCommitted, } from '../../db-sqlite-persistence-core/src' import type { Message } from '@electric-sql/client' +import type * as ElectricSdkModule from '@electric-sql/client' // Observe the real constructor boundary without changing the installed SDK's // options, prototype, methods, or HTTP behavior. Only the rotation lane enables @@ -57,7 +58,7 @@ const sdkConstruction = vi.hoisted(() => ({ observe: undefined as (() => void) | undefined, })) vi.mock(`@electric-sql/client`, async (importOriginal) => { - const original = await importOriginal() + const original = await importOriginal() return { ...original, ShapeStream: new Proxy(original.ShapeStream, { @@ -365,7 +366,7 @@ function recordVisibilityListeners() { call: [type, listener, options], adding, } of calls) { - if (type !== `visibilitychange` || listener === null) continue + if (type !== `visibilitychange`) continue const capture = typeof options === `boolean` ? options : Boolean(options?.capture) if (adding) { From c88dcfe5902be80892d2108f16b9ec764041dbe7 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 13:49:13 -0600 Subject: [PATCH 08/12] docs: correct oracle review evidence spacing --- .../oracle-reviews/2026-10-26-pr-2257.md | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md index af9eded76c..4bd5df8c58 100644 --- a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -374,7 +374,7 @@ explicitly identified as installed-provider/driver premises. | Item | Evidence before repair | Repair and resulting evidence | | --- | --- | --- | | R01: removal attempts mistaken for actual listener release | An SDK 1.5.15 variant removes with capture=true after registering capture=false. The previous release campaigns pass, but actual EventTarget dispatch invokes the retained handler. Five of seven focused identity/order traces disagree with the old recorder. | Recorder replays add/remove calls in global invocation order, keyed by callback and normalized capture; duplicate adds coalesce. Native dispatch before fixture teardown independently calibrates every length 0..3 history over two callbacks, add/remove, and five capture spellings: 8,421 histories. The original SDK variant now fails the cleanup comparison. | -| H01: pending async error decision absent from release histories | A conditional release that skips `unsubscribeAll()` only while a user decision remains pending passes all 46original runtime tests. A held-decision probe rejects it after cleanup, while current production passes all 6mode/future-outcome cells. | Generate held retry/give-up endings, observe entry into real installed-SDK `onError`, keep each promise unresolved through cleanup and later runs, and resolve only in teardown. Declare pending-decision cleanup reach. Both campaigns reject the conditional design; direct replays fail it and pass unchanged production. | +| H01: pending async error decision absent from release histories | A conditional release that skips `unsubscribeAll()` only while a user decision remains pending passes all 46 original runtime tests. A held-decision probe rejects it after cleanup, while current production passes all 6 mode/future-outcome cells. | Generate held retry/give-up endings, observe entry into real installed-SDK `onError`, keep each promise unresolved through cleanup and later runs, and resolve only in teardown. Declare pending-decision cleanup reach. Both campaigns reject the conditional design; direct replays fail it and pass unchanged production. | The expanded grammar retains row-only, up-to-date, reset, and retried-error prefixes; all three modes; one to three runs; and the previous open, give-up, @@ -467,7 +467,7 @@ The two independent enforcement repairs above do not depend on that decision. ### Review evaluation, requirement outcomes, and limits The 41 source-order items remain accounted for: 27 accepted-design, 2 fixed-now -(R01/H01),2 already-fixed, 8 duplicates, 1 refuted, 1 design-decision; none deferred. +(R01/H01), 2 already-fixed, 8 duplicates, 1 refuted, 1 design-decision; none deferred. Each retains its original claim, evidence and durable destination in the local ledger. The previous review-only stopping interpretation was superseded after re-reading evaluate-review's explicit authorization to repair enforcement gaps. @@ -664,3 +664,36 @@ Package ESLint passes for the changed Electric owner and core registry/manifest. The complete owner passes all 49 runtime and three type tests, including the 8,421-history native calibration and both generated laws. This is a mechanical follow-up to the evidence above; the 43-item review accounting is unchanged. + +## CodeRabbit follow-up review — 2026-10-26 + +Review `5657996284` covers `04b1c4b2299a95746c6f64521d54ca4b2804a194`. +Its three reported spacing defects in this record were confirmed and corrected. +The intervening package-lint commit changes no oracle law or checkpoint. +Source and formatting checks are the appropriate evidence for these text edits. + +The review body and mutable summary were inventoried separately from the +43-item agent evaluation above. Their 43 source-order entries, including +individual endorsed ranges, repeated claims, metadata, and suggestions, resolve +as **35 accepted-design + 3 fixed-now + 1 already-fixed + 1 refuted + +3 duplicates**. No confirmed-open or deferred item was introduced. The CI +suggestion concerns the lint failure already repaired by `997d21d79`. + +The reported 60% docstring score is a vendor metric; no repository rule imposes +its proposed 80% threshold. The owner already documents its contract, model, +histories, driver, comparisons, and limits. Adding comments solely for that +ratio would not improve the evidence. The native AppState caveat remains valid: +the checked-in lane observes installed SDK 1.5.15 in jsdom. The separately +recorded exact 1.5.23-source probe reaches automatic AppState selection with +controlled native premises, passes current production, and rejects the original +missing release. It does not establish actual iOS/Android runtime behavior or +all supported SDK versions. These limits remain explicit; no native-wide +closure or whole-PR readiness is inferred. + +Reviewer assessment: the spacing correction is precise and useful; endorsements +and the scope caveat are consistent with the recorded evidence. The unsupported +documentation threshold and lack of a new distinguishing experiment limit this +review's depth. Do not hire for independent oracle review on this evidence +alone. The final loss audit revisited the full review body and summary, +including ancillary suggestions and repeated details. No behavioral repair or +new experiment was required by this review. From 5e1b3ff0f09b176feac1c39ba47938c1ad79e35a Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 14:17:54 -0600 Subject: [PATCH 09/12] test(electric): observe retired cache targets after rotation --- docs/contributing/oracle-coverage.md | 2 +- .../oracle-reviews/2026-10-26-pr-2257.md | 131 ++++++++++++++++++ ...ctric-sdk-delivery-oracle.property.test.ts | 25 +++- 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 63fa0fa4cb..25ec2c7005 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -636,7 +636,7 @@ generic visitor behavior outside the named collectors. | QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, `packages/db/tests/transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md) | A finite array/Set model checks the compiled output bag for a joined DISTINCT subquery with an outer WHERE. Live-query drivers check selected containers, no-select, functional, and predicate-literal user objects with IR-like fields, top-level and aggregate-subquery proxy-shaped fields, arrays of selected references, outer virtual-field filters on joined DISTINCT and nested aggregate QueryRefs, and a renamed no-select source. The transaction suite documents the existing development-browser duplicate-load guard. The bounded cases run in `test:oracles` or the full DB suite. Joined DISTINCT, nested and flat aggregate source-update histories pass. Ordered joined `findOne()` checks initial, singleton, empty, restored, and changed-join-key cuts; an unordered `findOne()` QueryRef on either side of a join checks default-key candidate deletion and reinsertion. A materialized joined `findOne()` supplies a separate receiving formulation. Other `singleResult` forms, join forms, schedules, and cross-copy value handling outside the duplicate-load guard remain unproved. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | -| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/durable rows, late old-response rejection, and final cleanup comparisons remain. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated lane. | +| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/current-generation durable rows, late old-response rejection, and final cleanup comparisons remain. Each round also remembers its retired storage target and checks that the late response's absent key reaches neither that target after delivery nor after replacement settlement. The seventh declared state requires this retired-target observation. This constrains the expired session's row; unrelated retained rows remain allowed, and a separate run with a valid claim can still use a retired generation. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated lane. | | PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. A metadata-bearing `Collection.update` passes through an observed SDK watcher callback to the public and SQLite rows and an exact queued CRUD patch. Held callback ordering and synthetic zero-field cases remain controlled-only, and remote upload has no receiving fixture here. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | | SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), [reset/resume histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts), [setup reopen](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts), [reload class](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts), [dual-adapter resume snapshots](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts), [Browser composed-owner histories](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts), [Browser coordinator RPC](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts), [shared-driver fairness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [Node shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/node-driver.test.ts), [OP-SQLite shared-handle scheduling](https://github.com/TanStack/db/blob/main/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [request order](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts), [Electron IPC and composed owner](https://github.com/TanStack/db/blob/main/packages/electron-db-sqlite-persistence/tests/electron-ipc.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Core cache/remote rejection/peer/reopen histories, atomic reset/resume lineage, key-set evidence, dual-adapter races, queued coordinator reload/gap notifications during startup, unscheduled startup/reset overlap, and exact driver results. The setup-reopen owner checks that a fresh adapter's first read repairs an interrupted setup, every subset of missing indexes, triggers, and bookkeeping rows, and a schema change to the fresh-install schema with rows kept or reset; complete setup costs at most one driver call over a cached read. It excludes cache-generation claims and concurrent adapters; the held-registration resume cases cover a collected generation at the setup DDL and at the registry read. The reload class owner keeps the reload law, model, histories, driver loop, and comparison in one module, with four production drivers: the Node Browser driver (real Browser persistence and coordinator, one connection per tab), the Node Electron driver (real renderers and main handler with an in-process `ipcMain` double), the real Chromium OPFS page, and a real Electron app (renderer over real IPC to a main process running the Node persistence over `node:sqlite`). Histories cover 1 to 4 eager or managed Collections with any subset of missing derived setup facts, a schema change, a database missing a migrated column or index, lock grants 1 to 5 tasks after the request, statement latency where the host controls it, a local write during another startup read (issued before or after its grant), a write held in flight at another Collection's grant, a follower write, a write from a third tab, takeover (also while a follower read is held after an earlier follower or third-tab write, and while a follower that knows the departed leader's route has a source commit queued behind a held subset read, which it flushes after the grant or which the departed leader received and never answered), restart, and a tab closed while its terms wait. It checks skipped and repaired Collection and database setup, that no term runs ahead of work queued on its tab at its grant unless a leader need is live in that election (a need that an earlier election served exempts none), that needs elect and settle, that a takeover's queued source commit settles within one RPC timeout (one timeout and a retry when the departed leader received it), and an exact per-reload budget: one registry read per adapter, one row scan per tab, two startup snapshots per eager Collection (metadata before the source starts, rows at the baseline hydrate, both required by the Electric resume laws), no rescan after a commit the tab already knows, and no database setup statement when it is complete. The implementation on origin/main fails every fixed history: the original 24 on all four drivers, and the 5 that cover per-election needs on the Node Browser driver. Production on this branch passes all 29 on all four drivers. The Node drivers reject 14 production mutants by assertion, including a lasting need latch, an earlier served need from this tab or another, a need still waiting at the grant that is ignored, and a grant that keeps the departed leader's route. The Browser coordinator RPC owner's per-election cells also reject those latches and a delayed heartbeat from the departed leader that restores its route during an election. Real Chromium rejects the never-waiting barrier across its histories; the real Electron app rejects it in 10 of the 29 fixed histories; in the others, real IPC finishes the startup reads before any grant. Hold seams patch the adapter in the process that runs it. Not owned here: a barrier that skips queued but not yet running regular work, which driver transaction order and the writer lock hide in reload histories; the shared logical scheduling oracle owns it. The real Electron app uses `node:sqlite` because Electron's ABI cannot load better-sqlite3; a native better-sqlite3 build for Electron is not run. The shared core-adapter contract checks custom local comparator dispatch for ascending, descending, and comparator-equal public-key ordering at `loadSubset` completion. Host packages register this contract, but the local sqlite3 CLI run does not prove native host execution. Browser composes public source commits with per-collection elected-owner routing and covers the complete committed-transaction wire partition through deterministic Node transport seams. Remote-subset histories distinguish logical demand, physical acquisitions, exact acquisition leases, and released replay tombstones. Electron composes source commits with a per-collection renderer owner, IPC adapter, real SQLite, and reopen checks. The shared-driver fairness owner checks K=1 complete-logical-hydrate scheduling with identical fixed, random, and seed-plus-path campaigns. It records public rows, raw dequeue reach, and a persist-first FIFO hostile control. Its Chromium OPFS fixture refines the provider boundary but does not establish a browser matrix, elapsed-time latency, unbounded eventuality, or multi-process coordination. Same-handle Node and OP-SQLite tests cover transaction admission. Controlled OPFS page/worker histories cover ownership and diagnostic-cause retention. The reset/resume owners use sqlite3 CLI and in-memory node:sqlite seams; they do not prove multi-process WAL, mobile/Tauri, or other native-device execution. Distinct database handles rely on SQLite lock admission rather than one in-process queue. React Native hosts without async-context propagation must use the transaction driver supplied to the callback for nested work. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The Browser composed seams are not real multi-context/OPFS-worker execution; the Electron harness is not an actual Electron process unless its explicit runtime-bridge mode runs. An ownerless elected node suppresses core routing, while a follower may route demand to the elected owner. The follower generation-notice and rotated-reacquisition lanes state the rotation law: a rotated follower holds the new generation's commit notices until the owner has reacquired every active demand, then ends scoped recovery and applies them. Production does not yet end that recovery, so their cells that require it are pinned as failing under the known gap [#2139](https://github.com/TanStack/db/issues/2139); the random generation-notice campaign is omitted until the fix, because some of its histories pass today. Cells that still pass cover failed reacquisitions (scoped recovery stays and the failure is reported), a superseding rotation as leader, and a scoped recovery without rotation, with or without an active demand (untrusted durable rows stay hidden). Other cells cover a follower whose claim expires while its ensure is in flight and an owner reset (the follower requests its demand again). The follower remote-demand lane checks that a follower's load, and a live query over its demand, settle only after the owner's broadcast applies here, for a broadcast that applies at once, one queued behind another subset's hydration, and one while the cache claim is expired; the claim-rotation placement is pinned under #2139. A failed apply rejects the load, errors the query, and fails the source. A renewal that fails transiently at the reply rejects the load but keeps and retries the demand, without releasing the owner's acquisition; a rotation during the reply wait is tracked under #2139. The flush-side expiry check and arrival-order queueing have no killing witness yet. Removing the follower's wait on either ensure path, its terminal check, or the queued flush's terminal marking each fails it. The Electron IPC readiness oracle checks a fresh or reopened renderer, a successful or failed IPC hydration, and an upstream load that succeeds, fails, or is released during hydration, against a model of the three readiness checkpoints; a renderer that replies before the main-process read or treats an IPC failure as empty fails it. The owner is a controlled coordinator double, not two real coordinators; host coordinators retry only classified transport or admission failures while demand remains retained. The manifest excludes progressive and move suites; registration and shim runs are not device execution. The per-collection coordinator oracle's real-tab lane runs a follower and an owner tab with real Browser coordinators over Node's BroadcastChannel and Web Locks and one real SQLite file: 16 histories over the owner's source (custom or Query Collection), the broadcast's placement, the follower's apply outcome, and the consumer, plus a rotation cell. Main fails all 16 histories; an owner tab for a rotated follower generation is not reachable there. The Electron IPC readiness oracle adds a follower and an owner renderer with Electron coordinators: 4 histories over the apply outcome and the consumer, in process and through a real Electron main process; main fails all 4. | | SQLite expression-index planning | [Node expression-index oracle](https://github.com/TanStack/db/blob/main/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts) | RFC #1659 invariant 8 owns identical persisted-index and runtime-expression shapes. Independent expected keys are checked against direct captured SQL, adapter results, and named-index plans. Generated BigInts use SQLite's signed range; one fixed case checks legacy oversized-value reads. Other limits: bounded unqualified JSON paths/scalars, Node BetterSQLite, and no null, arbitrary raw SQL, or native-host planning. Run the package's `test:oracles` campaign. | diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md index 4bd5df8c58..50663658e5 100644 --- a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -697,3 +697,134 @@ review's depth. Do not hire for independent oracle review on this evidence alone. The final loss audit revisited the full review body and summary, including ancillary suggestions and repeated details. No behavioral repair or new experiment was required by this review. + +## Retired durable-target observation — 2026-10-26 + +CodeRabbit review `5658197144`, item N01, identifies an observation gap in the +managed-rotation owner. The review covers +`997d21d7900bd6a9deed692942ea8d54bf2fdc6b`; this evaluation starts at +`c88dcfe5902be80892d2108f16b9ec764041dbe7`, whose intervening change affects +only this record. The reviewed owner hash is the `f6d2a06d` hash above. +N01 is **fixed-now**. It reveals no reproduced production routing defect. + +### Law and repair + +The SQLite persistence guide's managed-generation contract requires immediate +retirement of expired source callbacks. The Electric guide's “Cleanup and +resume safety” contract fences the retired provider session's late responses. +Its adjacent recovery contract permits another run with a valid claim to keep +using a retired persisted cache generation. Therefore this expired session's +late row cannot enter either durable target; the whole retired map need not be +empty or immutable. + +The independent source model already says that the old predicate has no row. +Each round uses a fresh odd key and now remembers its retired storage target. +The oracle compares that key's absence after the late HTTP response and after +replacement settlement. All prior current-cache, public-row, constructor, +demand-settlement, and cleanup comparisons remain. The grammar still generates +one to three rotations and all three delivery cuts. This adds an observation +and a seventh required reach state, without changing production or model rules. + +### Evidence block + +- **Law:** expired Electric provider-session callbacks have no authority to + persist their late row through either generation; the public guides above + supply the authority. A valid run's retained generation remains usable. +- **Reach:** normal fixed/random campaigns each run 40 histories. In declaration + order, the seven final counts are `19/23, 25/18, 17/21, 40/40, 26/22, 40/40, + 40/40`: during rotation, before snapshots, after snapshots, initial retirement, + replacement retirement, demands awaiting replacement, and retired target + observed. Every minimum is one history. +- **RED:** the old owner accepts a forbidden `oldId` write into the captured + retired map through the fixture's real `applyCommittedTx`, in fixed 40 and + random 40 histories. This confirms the missing observation. With the repaired + owner, both campaigns reject that incorrect result at + `retired response in retired cache: expected true to be false`. +- **GREEN:** unchanged production passes the repaired owner in fixed 40 and + random 40 histories. The complete owner passes 49 runtime and three type + tests with no type errors. Electric package ESLint passes. +- **Mutant:** this is a wrong-result control at the durable fixture boundary, + not a production mutation or evidence that production misroutes callbacks. + The fixed RED starts with `["after-snapshots", "before-snapshots"]` and shrinks + to `["during-rotation"]`, seed `20261026`, path `0:0:0`, preserving the + assertion. Exact replay fails with the control and passes unchanged + production. A second direct replay inserts the wrong row only at the final + cut and fails `retired response in retired cache after replacement`. +- **Limits:** real installed SDK 1.5.15, controlled HTTP, jsdom, one on-demand + Collection, Map-backed durable storage, sequential one-to-three rotations, + and the stated observation cuts. Actual SQLite peer-claim enforcement, + overlapping rotations, native hosts, all supported SDK versions, and a write + inserted and removed entirely between cuts are not established. + +A neighboring-state control seeds an unrelated negative key before expiry and +checks that it remains in retired storage after replacement. Both campaigns +pass. This calibrates acceptance of allowed retained contents; it does not +execute a real peer claim. An earlier scratch control named `valid-peer-write` +used the fixture's one claim ID and is not credited as peer authorization. + +Final owner SHA256: +`7ccf1eb989a5d5a33876601bbca816b35069a5c70352d45ade2631bf58217005`. +Unchanged Electric production SHA256: +`4919ad548c416952fe51822b9e1d06c20dd5807b6dda40909bc3ed7046b66015`. +The diagnostic baseline owner hash is +`1b2ed73a4177382b726445f6b028d945e466561e33f0b41d93b4ee99556b1af4`; +the immediate wrong-write owner hash is +`9db1697989749a49fc9a22fb2ea944c6a691928cb2730d3ee90ea83e40a6dcc0`. +Only diagnostics and the specified control distinguish these executed files +from the final owner. The private Vite config resolves the private production +source and real installed SDK explicitly; logs record the positive source +witness. The snapshot owner is restored after the controls. + +The normal command is: + +```sh +pnpm --dir packages/electric-db-collection exec vitest run tests/electric-sdk-delivery-oracle.property.test.ts --coverage.enabled=false --maxWorkers=2 +``` + +The direct replay uses the same command with `-t replay` and +`TANSTACK_DB_ORACLE_SEED=20261026`, `TANSTACK_DB_ORACLE_PATH=0:0:0`, and +`TANSTACK_DB_ORACLE_PROPERTY=electric.sdk-rotation-release`. Wrong-result +controls run that replay in the private source-bound snapshot, without editing +shared production. Raw owners, patches, manifests, commands, and logs remain in +the task's `/tmp/pr2257-ci-loop/retired-map/` evidence directory. + +### Guide conformance and disposition + +ORC-001/002/003: public authority and the absent-key prediction are explicit +beside the model; the five responsibilities remain distinguishable. ORC-004: +one round reconstructs the observation gap; repeated rounds distinguish +replacement retirement; each delivery cut supplies a different retirement +interval. The grammar answers only admitted requests. ORC-005/006: real +SDK/Collection/persistence paths run, and both new durable comparisons reject +their intended wrong result. ORC-007: equal fixed/random budgets and the direct +replay fail/pass pair are recorded. ORC-008 adds no stateful-model distinction; +the captured target is a driver observation. ORC-009 preserves the glossary's +separate provider session, sync run, and persisted cache generation. ORC-010: +shrinking retains the durable failure, primitive observations survive cleanup, +and fixture resources are released. ORC-011 has no named shared semantic +computation requiring a second formulation; the expected absent key comes from +the source model. ORC-012 is this source-bound record. ORC-013 distinguishes +immediate/final wrong writes and allowed retained contents. ORC-014 credits real +SDK delivery and limits the durable premise to the Map fixture, without a real +SQLite/native handoff claim. ORC-015 requires and measures seven reach states. + +The old comparison's false green came from an unobserved durable target, not +missing histories or a copied expected result. Capturing that target and +observing it across the existing grammar is the smallest general repair. +No known reachable in-scope counterexample remains from N01. This bounded +observation repair does not establish whole-PR readiness. N02, N03, and U05 +repeat N01 and retain their separate ledger identities. + +Reviewer assessment for N01: accurate, concrete, and appropriately low severity. +The proposed small assertion repair is useful. The valid-retained-generation +distinction required additional examination. Hire for this focused enforcement +review; this evidence alone does not establish broader law-design depth. + + +A fresh read-only rereview checked final owner `7ccf1eb9` and found no further +supported defect. It independently passed the targeted three-test rotation +baseline. The complete second CodeRabbit inventory contains 21 source-order +items, including repeated prompts and mutable-summary metadata: +**9 accepted-design + 1 fixed-now + 11 duplicates**. No item from that review +remains open or deferred. This accounting is separate from the earlier agent +and CodeRabbit inventories. diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts index 5ffb6f10c8..832b8ae314 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts @@ -1027,10 +1027,15 @@ fixedCase(`does not certify an aborted SDK subset snapshot`, async () => { * still in flight. The source model has no row for the old predicate and one * row for the new predicate. The old provider session loses authority before * cache rotation; its late HTTP response cannot publish or persist a row in - * the new generation. The replacement session must settle both demands from - * its own snapshots. This receives the controlled provider-restart law through - * the installed ShapeStream and HTTP path. A Map-backed adapter supplies the - * generation boundary; real SQLite and native hosts have separate owners. + * either generation. The SQLite persistence guide requires immediate retirement + * of expired source callbacks. This restriction belongs to this expired session; + * a peer with a valid claim may still use a retired generation. Each round + * remembers its retired storage target and excludes only the late response's + * absent key there, allowing other retained rows. The replacement session must + * settle both demands from its own snapshots. This receives the controlled + * provider-restart law through the installed ShapeStream and HTTP path. A + * Map-backed adapter supplies the generation boundary; real SQLite and native + * hosts have separate owners. * It also receives the stream-listener release law below for the retired * session. The Electric guide requires releasing its owned resources before * constructing the replacement ShapeStream. The independent rule is zero @@ -1057,6 +1062,7 @@ const createRotationReach = () => | `first-session` | `replacement-session` | `demands-await-replacement` + | `retired-target-observed` >(`electric.sdk-rotation-release`, [ `during-rotation`, `before-snapshots`, @@ -1064,6 +1070,7 @@ const createRotationReach = () => `first-session`, `replacement-session`, `demands-await-replacement`, + `retired-target-observed`, ]) let rotationReach: ReturnType | undefined @@ -1243,6 +1250,7 @@ async function checkManagedRotation(cuts: ReadonlyArray) { rotationRequested = deferred() const oldId = round * 2 + 1 const newId = oldId + 1 + const retiredStorageId = storageId const oldOptions = demand(oldId) const newOptions = demand(newId) const settlements: Array<`pending` | `fulfilled` | `rejected`> = [] @@ -1285,6 +1293,11 @@ async function checkManagedRotation(cuts: ReadonlyArray) { respond(oldSnapshot, { id: oldId, name: `late-old` }, round * 4 + 1) await new Promise((resolve) => setTimeout(resolve, 0)) expect(collection.get(oldId)).toBeUndefined() + expect( + rows.get(retiredStorageId)!.has(oldId), + `retired response in retired cache`, + ).toBe(false) + rotationReach?.reach(`retired-target-observed`) } if (retiredSnapshotCut === `during-rotation`) await deliverRetiredSnapshot() @@ -1338,6 +1351,10 @@ async function checkManagedRotation(cuts: ReadonlyArray) { expect(collection.get(oldId)).toBeUndefined() expect(collection.get(newId)?.name).toBe(`fresh`) expect([...rows.get(storageId)!.keys()]).toEqual([newId]) + expect( + rows.get(retiredStorageId)!.has(oldId), + `retired response in retired cache after replacement`, + ).toBe(false) expect(listeners.registered()).toBe(round + 2) expect(listeners.live(), `live listeners after rotation`).toBe(1) // End these demands before the next round; its fresh IDs ensure an old From 42e2d9ca8facf4ee33a59bb053789d7823c7207c Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 14:38:47 -0600 Subject: [PATCH 10/12] test(db): distinguish initiating request failures during replay --- docs/contributing/oracle-coverage.md | 15 +- .../oracle-reviews/2026-10-26-pr-2257.md | 116 ++++++++- packages/db/tests/oracle-config.ts | 1 + packages/db/tests/oracle-replay-manifest.ts | 9 + ...ad-subset-replay-refinement-oracle.test.ts | 236 ++++++++++++++++-- 5 files changed, 350 insertions(+), 27 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 25ec2c7005..a91bc29418 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -63,7 +63,7 @@ that test identifiers must copy production's private data structures. | Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | | Query DB and observer | Complete | Query-scope row ownership, subset identity and cancellation, failure and recovery, and the per-listener eligibility ledger are literate. | | Query DB SSR dehydration | Fixed QueryClient/Seroval boundary | Successful on-demand entries with a compound predicate, a request signal, and an ordered cursor with a custom comparator retain their row and user metadata through Seroval's reconstructed stream payload and Query Core hydration; request options are absent from the stream, while query functions retain the original values. The TanStack Start router and browser Collection resume path still need a host witness. | -| Ordered acquisition | Complete | Exact demand identity, applied settlement, independent pagination recomputation, request work, lifecycle products, replay authority, source-generation readiness, and transaction-refinement abort boundaries are literate. An initial full-source ordered load shares the synchronous readiness cut ([historical review](oracle-reviews/initial-full-source-sync-readiness.md)). The ordered-lifecycle oracle covers an inexpressible-boundary fallback before first publication when every acquisition returns literal `true`. It also checks a later null-boundary fallback after the first joined window publishes, across literal, Promise, and held-receipt responses, including a held receipt after child demand settles ([review](oracle-reviews/issue-2152-initial-ordered-fallback.md)). The ordered loader state oracle also has a generated request-authority lane (`ordered-loader.request-authority`, fixed and random campaigns). It drives the real loader over a fake subscription through settlements in any order, failures, deferred invalidation and report reactions, ordinary passes and window moves, and began with four laws from a TLA+ model (#2191): a successful repair keeps its acquisitions until a covering read succeeds; quiet loading holds nothing older than its last authoritative read; a superseded request's failure is recovered by a full-source read without an explicit retry; and once quiet, an invalidation and a pass issue a read unless a full-source read covers the source. Scenarios also throw from the first release callback, answer a repair synchronously from installed rows, and let a page's own delivery invalidate ordering; named replays keep the traces they found, each of which failed on main (a throwing release that unwound a resumed repair, a synchronous repair waiting behind a superseded request, and a self-invalidating page whose tie was never retired). On main it found a superseded tie that failed after its repair finished and a newer invalidation's reaction was deferred, which left refills blocked; a named replay keeps that trace. A stale prefix that retires the repair's prefix fails the held law, and a stale request held after the repair finished fails the leak law. The lane also crosses unexpressible boundaries, joined work that defers loading, truncate replay that succeeds or stays pending, optimistic departures, source deletes, the bounded backoff retry, window moves that shrink or widen, plans that read only the full source, and reports inside the loader's own first page. A replay owns the requests issued before its truncate, and when the subscription still holds a full-source lease the settled replay covers the source. These dimensions found three more defects, each kept as a named replay: a repair prefix that settled after a replay started its replacement took the replacement's chain tag and was never retired; a replay counted a full-source read as complete after an explicit retry had released it; and a failure from before a truncate dropped the next repair's staged step. Each of the seven defect replays fails on the loader at the branch's merge base with main. A later law, one request at a time, found that a replaced repair chain's prefix still counted as current and issued a tie beside the replacement's prefix; a chain is now superseded by any newer authority start. The failed-lease law also counts a covering replay, which a named replay reaches. Repeated random campaigns then found a repair request that settled after full-source coverage and stayed held; full coverage now retires it. The held law counts a later prefix that a successful replay reacquired while it was in flight. Four histories from TCP's known error classes followed: a request the provider never answers (a lost segment), a release callback that truncates synchronously (a reentrant reset), a window offset other than zero, and an at-most-once release law with no release while a replay owns the demand unless an explicit retry replaces it or a held full-source lease covers it. They found that the backoff retry's release pass kept releasing failed demands after a release started a truncate; every release pass now stops on a reentrant reset. The oracle now observes epoch moves as calls to `invalidateSourceOrdering()`, so it tells a replacement chain from a chain step exactly. The loader's work slot and debt now move only through a transition table that throws `OrderedLoaderInvariantError` on an illegal move or an incoherent work and debt pair; the oracle records any such error as a broken law, so its generated histories fuzz the table. A covering-lease law (a full-source lease whose read succeeded or whose demand a replay reacquired is never released) found that a full-source transport failing after a covering replay turned complete coverage into an own failure; the table then found that a repair request failing after a replay finished its chain did the same. Mutants that drop each new guard fail. A release callback that runs an ordinary pass synchronously, as a graph run does when the release delivers deletes outside a transaction, found that the pass issued a page while another request was settling, and that request then issued its tie; every release pass now blocks refinement until it unwinds. A dispose command and a release callback that disposes the loader check that a disposed loader issues and releases nothing; mutants that drop the loader's own `active` checks in the release pass and in failure handling survive because dispose resets the cursor, so those checks were removed. TLA+ run 3 (`LoaderTable.tla`) models the table with truncate and replay; its safe rules pass with 9,368,122 distinct states, and each of the four guards from this round, removed alone, produces a counterexample. Reverting any one fix fails its replay and a campaign. The generated histories also stack a second truncate on a pending replay and truncate after a failed replay, each with a reach witness. A lease missing from its map during a release pass is now an invariant violation; no history reaches it. A second high review found that a covering replay did not retire a superseded failed lease from before its truncate, because the failed-lease law exempted every replay-owned request. The law now judges a superseded failure that was live (it failed in the incarnation it was issued in) once a covering replay settles after it, and the replay retires those leases. The subscriber now schedules the replay's graph run in a finally. The replay-refinement oracle is the receiving witness: its generated ordered section (`replay.ordered-window-faults`) drives a real on-demand Collection and an ordered live query through rank moves, held and failed loads, truncates, and unloads that throw without a truncate, while a replay starts, and while it settles. Its settled-window law found that a release throwing after a successful ordered load left the window on old rows, because the builder counted the rejected participant as a failed load (also on main); the loader now marks release errors and the builder publishes. It also fails without the subscriber's finally. The section now also crosses tied ranks (its law compares the window's rank sequence and each shown row's current rank), a peer live query on the same source, offset 1, descending order, and explicit window moves; a move that rejects after a failed source recovery leaves the window, and one rejected only by a release failure still moves. A reported-faults law accepts injected faults as rejected loads, thrown releases, rejected moves, or errors the subscription rethrows from a microtask, and rejects any other escaped error. These found that a window move whose load succeeded was recorded as failed when a release threw, so the window stayed private until another move. A throwing release is an adapter bug, so the loader now reports it uncaught from a microtask and changes nothing else; the reported-faults law rejects a move that a release failure rejects, and the request-authority oracle requires each throwing release to be reported uncaught exactly once (a mutant that swallows the error fails). It also crosses a joined marker source: a LEFT join that keeps marked or unmarked rows, and an inner join. The marker source is eager or on-demand, with marker loads that are held, fail, and truncate. This found that an on-demand joined source whose loads finished without delivering a row left the window on old rows, because nothing ran the graph that the pending joined work held; the subscriber now runs it when a joined source becomes ready. A move whose load a truncate aborts may reject with an `AbortError` and leaves the window; a named replay reaches that branch. Recovery truncates the marker source too, because a failed marker replay keeps the last complete publication until that source replays. A hostile-mutant pass over the loader's guards found guards that no ordered oracle kills. A differential instrument then ran 3,000 generated request-authority histories through the loader and each mutant and compared every request's shape, outcome, and release. Three guards the code makes redundant are deleted: the backoff timer guard, the explicit-move check in the retry callback, and the `requiresFullSource` check on a report. An ignored-report law now kills two of the mutants that changed public requests: a report issues no authoritative request before any request settled (a request in flight still owes an exact result for its own options) or while the window is inside the loader's own first prefix. Named replays reach both, and the random campaigns judge them in quiet histories. One mutant still changes requests without a law: dropping the optimistic-refill flag turns a refill into a refetching prefix. Six more are equivalent in the fake but stay, because the fake cannot reach their premise or they encode a documented rule: the own-first-page check behind a held sync transaction (the fake never holds one), a truncate moving complete coverage back to held, clearing the pending request when a full-source read starts (the fake never answers a full-source read synchronously), staging a joined `more` continuation, the staged-continuation guard before an unindexed prefix, and the drain re-entry guard (a long synchronous chain would otherwise recurse). An include dimension adds a `toArray` child that reads the marker source for each shown row, and an include-children law requires each shown row's markers to equal the model wherever the window law holds; a mutant that never loads newly demanded child keys fails it in an include-only scenario. A newest-move law requires a superseded window move to resolve only after the newest move settles (a superseded move that fails still rejects with its own error), and the model lets the newest move that resolved set the window; it found that a superseded move resolved as soon as its own load settled, so awaiting it showed neither window. The terminal-error law also allows `error` after a load fails while the query is still loading, the documented initial-load failure; a lazy join source is exempt. Limits: includes are one level deep. It also found that a release failure in lazy joined demand during a window move rejected the move, because the subscription reported it as a load error. A window law now lets the newest move win; it found that two moves deferred by a pending source recovery could resume in the opposite order, so the older request replaced the newer window. `setWindow()` now ignores a deferred call that a newer call superseded. A terminal-error law allows a final `error` only after a lazy join demand load, on either side, failed outside a replay; named replays reach that branch and the aborted-move branch. Core now reports a throwing `unloadSubset` uncaught and fails nothing with it; the subscription lifecycle, replay, Effect disposal, includes-temporal, and subset-error oracles check the uncaught report, once, with the adapter's error. The request-authority oracle now also generates requests that throw while they start, the last history only TLA+ covered, with an error-identity law: a failed request's participant reports that request's own error. It found that a superseded failure whose recovery read threw at its start reported the start error instead (review finding 6). A cost law then checks, after loading is quiet and a bounded repair is available, that one invalidation reads no full source and issues at most one chain per ordering epoch move (in the fake, a prefix, a tie, and a refill); a mutant that always repairs with a full-source read fails it. The replay-refinement section's status law requires a quiet live query and its peer to stay ready when no load failed. Limits: whether a release runs the graph at once or defers it to a transaction belongs to the scheduler, and the oracle generates both forms without checking the scheduler's choice; the fake's rows do not move except for the unexpressible boundary; publication holds stay with other tests; the laws do not judge requests issued before a truncate; a hung authoritative request suspends the leak law for its unfinished chain; a recovery start that throws during a superseded failure is rare in the campaigns, so a named replay owns it. | +| Ordered acquisition | Complete | Exact demand identity, applied settlement, independent pagination recomputation, request work, lifecycle products, replay authority, source-generation readiness, and transaction-refinement abort boundaries are literate. An initial full-source ordered load shares the synchronous readiness cut ([historical review](oracle-reviews/initial-full-source-sync-readiness.md)). The ordered-lifecycle oracle covers an inexpressible-boundary fallback before first publication when every acquisition returns literal `true`. It also checks a later null-boundary fallback after the first joined window publishes, across literal, Promise, and held-receipt responses, including a held receipt after child demand settles ([review](oracle-reviews/issue-2152-initial-ordered-fallback.md)). The ordered loader state oracle also has a generated request-authority lane (`ordered-loader.request-authority`, fixed and random campaigns). It drives the real loader over a fake subscription through settlements in any order, failures, deferred invalidation and report reactions, ordinary passes and window moves, and began with four laws from a TLA+ model (#2191): a successful repair keeps its acquisitions until a covering read succeeds; quiet loading holds nothing older than its last authoritative read; a superseded request's failure is recovered by a full-source read without an explicit retry; and once quiet, an invalidation and a pass issue a read unless a full-source read covers the source. Scenarios also throw from the first release callback, answer a repair synchronously from installed rows, and let a page's own delivery invalidate ordering; named replays keep the traces they found, each of which failed on main (a throwing release that unwound a resumed repair, a synchronous repair waiting behind a superseded request, and a self-invalidating page whose tie was never retired). On main it found a superseded tie that failed after its repair finished and a newer invalidation's reaction was deferred, which left refills blocked; a named replay keeps that trace. A stale prefix that retires the repair's prefix fails the held law, and a stale request held after the repair finished fails the leak law. The lane also crosses unexpressible boundaries, joined work that defers loading, truncate replay that succeeds or stays pending, optimistic departures, source deletes, the bounded backoff retry, window moves that shrink or widen, plans that read only the full source, and reports inside the loader's own first page. A replay owns the requests issued before its truncate, and when the subscription still holds a full-source lease the settled replay covers the source. These dimensions found three more defects, each kept as a named replay: a repair prefix that settled after a replay started its replacement took the replacement's chain tag and was never retired; a replay counted a full-source read as complete after an explicit retry had released it; and a failure from before a truncate dropped the next repair's staged step. Each of the seven defect replays fails on the loader at the branch's merge base with main. A later law, one request at a time, found that a replaced repair chain's prefix still counted as current and issued a tie beside the replacement's prefix; a chain is now superseded by any newer authority start. The failed-lease law also counts a covering replay, which a named replay reaches. Repeated random campaigns then found a repair request that settled after full-source coverage and stayed held; full coverage now retires it. The held law counts a later prefix that a successful replay reacquired while it was in flight. Four histories from TCP's known error classes followed: a request the provider never answers (a lost segment), a release callback that truncates synchronously (a reentrant reset), a window offset other than zero, and an at-most-once release law with no release while a replay owns the demand unless an explicit retry replaces it or a held full-source lease covers it. They found that the backoff retry's release pass kept releasing failed demands after a release started a truncate; every release pass now stops on a reentrant reset. The oracle now observes epoch moves as calls to `invalidateSourceOrdering()`, so it tells a replacement chain from a chain step exactly. The loader's work slot and debt now move only through a transition table that throws `OrderedLoaderInvariantError` on an illegal move or an incoherent work and debt pair; the oracle records any such error as a broken law, so its generated histories fuzz the table. A covering-lease law (a full-source lease whose read succeeded or whose demand a replay reacquired is never released) found that a full-source transport failing after a covering replay turned complete coverage into an own failure; the table then found that a repair request failing after a replay finished its chain did the same. Mutants that drop each new guard fail. A release callback that runs an ordinary pass synchronously, as a graph run does when the release delivers deletes outside a transaction, found that the pass issued a page while another request was settling, and that request then issued its tie; every release pass now blocks refinement until it unwinds. A dispose command and a release callback that disposes the loader check that a disposed loader issues and releases nothing; mutants that drop the loader's own `active` checks in the release pass and in failure handling survive because dispose resets the cursor, so those checks were removed. TLA+ run 3 (`LoaderTable.tla`) models the table with truncate and replay; its safe rules pass with 9,368,122 distinct states, and each of the four guards from this round, removed alone, produces a counterexample. Reverting any one fix fails its replay and a campaign. The generated histories also stack a second truncate on a pending replay and truncate after a failed replay, each with a reach witness. A lease missing from its map during a release pass is now an invariant violation; no history reaches it. A second high review found that a covering replay did not retire a superseded failed lease from before its truncate, because the failed-lease law exempted every replay-owned request. The law now judges a superseded failure that was live (it failed in the incarnation it was issued in) once a covering replay settles after it, and the replay retires those leases. The subscriber now schedules the replay's graph run in a finally. The replay-refinement oracle is the receiving witness: its generated ordered section (`replay.ordered-window-faults`) drives a real on-demand Collection and an ordered live query through rank moves, held and failed loads, truncates, and unloads that throw without a truncate, while a replay starts, and while it settles. Its settled-window law found that a release throwing after a successful ordered load left the window on old rows, because the builder counted the rejected participant as a failed load (also on main); the loader now marks release errors and the builder publishes. It also fails without the subscriber's finally. The section now also crosses tied ranks (its law compares the window's rank sequence and each shown row's current rank), a peer live query on the same source, offset 1, descending order, and explicit window moves; a move that rejects after a failed source recovery leaves the window, and one rejected only by a release failure still moves. A reported-faults law accepts injected faults as rejected loads, thrown releases, rejected moves, or errors the subscription rethrows from a microtask, and rejects any other escaped error. These found that a window move whose load succeeded was recorded as failed when a release threw, so the window stayed private until another move. A throwing release is an adapter bug, so the loader now reports it uncaught from a microtask and changes nothing else; the reported-faults law rejects a move that a release failure rejects, and the request-authority oracle requires each throwing release to be reported uncaught exactly once (a mutant that swallows the error fails). It also crosses a joined marker source: a LEFT join that keeps marked or unmarked rows, and an inner join. The marker source is eager or on-demand, with marker loads that are held, fail, and truncate. This found that an on-demand joined source whose loads finished without delivering a row left the window on old rows, because nothing ran the graph that the pending joined work held; the subscriber now runs it when a joined source becomes ready. A move whose load a truncate aborts may reject with an `AbortError` and leaves the window; a named replay reaches that branch. Recovery truncates the marker source too, because a failed marker replay keeps the last complete publication until that source replays. A hostile-mutant pass over the loader's guards found guards that no ordered oracle kills. A differential instrument then ran 3,000 generated request-authority histories through the loader and each mutant and compared every request's shape, outcome, and release. Three guards the code makes redundant are deleted: the backoff timer guard, the explicit-move check in the retry callback, and the `requiresFullSource` check on a report. An ignored-report law now kills two of the mutants that changed public requests: a report issues no authoritative request before any request settled (a request in flight still owes an exact result for its own options) or while the window is inside the loader's own first prefix. Named replays reach both, and the random campaigns judge them in quiet histories. One mutant still changes requests without a law: dropping the optimistic-refill flag turns a refill into a refetching prefix. Six more are equivalent in the fake but stay, because the fake cannot reach their premise or they encode a documented rule: the own-first-page check behind a held sync transaction (the fake never holds one), a truncate moving complete coverage back to held, clearing the pending request when a full-source read starts (the fake never answers a full-source read synchronously), staging a joined `more` continuation, the staged-continuation guard before an unindexed prefix, and the drain re-entry guard (a long synchronous chain would otherwise recurse). An include dimension adds a `toArray` child that reads the marker source for each shown row, and an include-children law requires each shown row's markers to equal the model wherever the window law holds; a mutant that never loads newly demanded child keys fails it in an include-only scenario. A newest-move law requires a superseded window move to resolve only after the newest move settles (a superseded move that fails still rejects with its own error), and the model lets the newest move that resolved set the window; it found that a superseded move resolved as soon as its own load settled, so awaiting it showed neither window. The terminal-error law also allows `error` after a load fails while the query is still loading, the documented initial-load failure; a lazy join source is exempt. Limits: includes are one level deep. It also found that a release failure in lazy joined demand during a window move rejected the move, because the subscription reported it as a load error. A window law now lets the newest move win; it found that two moves deferred by a pending source recovery could resume in the opposite order, so the older request replaced the newer window. `setWindow()` now ignores a deferred call that a newer call superseded. A terminal-error law allows a final `error` only after a newly initiated keyed snapshot request, on either side, rejects with an injected load error, or an initial load fails; a new include request can begin during a truncate commit, whereas reacquisition of an established request remains an ordinary nonterminal replay; named replays reach that branch and the aborted-move branch. Core now reports a throwing `unloadSubset` uncaught and fails nothing with it; the subscription lifecycle, replay, Effect disposal, includes-temporal, and subset-error oracles check the uncaught report, once, with the adapter's error. The request-authority oracle now also generates requests that throw while they start, the last history only TLA+ covered, with an error-identity law: a failed request's participant reports that request's own error. It found that a superseded failure whose recovery read threw at its start reported the start error instead (review finding 6). A cost law then checks, after loading is quiet and a bounded repair is available, that one invalidation reads no full source and issues at most one chain per ordering epoch move (in the fake, a prefix, a tie, and a refill); a mutant that always repairs with a full-source read fails it. The replay-refinement section's status law requires a quiet live query and its peer to stay ready when no load failed. Limits: whether a release runs the graph at once or defers it to a transaction belongs to the scheduler, and the oracle generates both forms without checking the scheduler's choice; the fake's rows do not move except for the unexpressible boundary; publication holds stay with other tests; the laws do not judge requests issued before a truncate; a hung authoritative request suspends the leak law for its unfinished chain; a recovery start that throws during a superseded failure is rare in the campaigns, so a named replay owns it. | | Indexed predicate filtering | Complete for bounded dotted-path collision | An independent row filter checks direct predicates and public callback predicates for a dotted scalar field beside a nested path, with and without an index. Selected-field ordering checks the same distinction through `$selected`. | | WHERE predicate publication | Complete for bounded predicate and sync-transaction grammar | The contract, Kleene reference evaluator, snapshot and change-history grammar, three subscriber consumers plus direct snapshot, and per-commit key-set refinement check are literate. A fixed same-key update checks live-row and direct-subscriber payloads; a focused descriptor boundary checks stored-row prefilter safety. Generated cleanup and restart histories for filtered subscribers remain open. | @@ -269,6 +269,19 @@ retired-route publication; and `packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts` checks before, during, and after-commit abort boundaries with applied receipts. These fixtures do not establish real-provider cancellation or framework timing. +The replay-refinement status driver observes initiating keyed snapshot-request +receipts rather than inferring logical demand from truncate callback timing. +Its generated histories declare and measure initiating keyed-request rejection +and keyed-reacquisition failure separately. A fresh include request issued +during marker truncate can fail terminally; failed reacquisition of an +established include or marker request retains the last publication until +recovery. The status comparison does not independently establish correct +settlement of the initiating receipt. Peer scenarios retain one history-level +failure allowance, so per-receiver failure isolation remains outside this +comparison; the owner is this replay-refinement oracle, with a needed witness +that fails one receiver's current request while comparing another receiver's +status at the same checkpoint. This is a stated observation limit, not a +retirement of the per-demand error-ownership law. The ordered-work owner has a 96-cell optimistic-departure matrix over source, joined, and anti-joined filters; update and delete; Collection and Effect; diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md index 50663658e5..5e1321b4b9 100644 --- a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -820,7 +820,6 @@ The proposed small assertion repair is useful. The valid-retained-generation distinction required additional examination. Hire for this focused enforcement review; this evidence alone does not establish broader law-design depth. - A fresh read-only rereview checked final owner `7ccf1eb9` and found no further supported defect. It independently passed the targeted three-test rotation baseline. The complete second CodeRabbit inventory contains 21 source-order @@ -828,3 +827,118 @@ items, including repeated prompts and mutable-summary metadata: **9 accepted-design + 1 fixed-now + 11 duplicates**. No item from that review remains open or deferred. This accounting is separate from the earlier agent and CodeRabbit inventories. + +## CF01 — ordered replay oracle classification repair + +The DB CI counterexample is an oracle boundary-classification defect under the +accepted demand/replay contracts. Production is unchanged. Baseline is PR head +`c88dcfe5902be80892d2108f16b9ec764041dbe7`; shared integration is based on +`5e1b3ff0f09b176feac1c39ba47938c1ad79e35a`. + +- **Law:** a newly initiated lazy keyed request may fail terminally even when + truncate causes it. A failed reacquisition of an established request is an + ordinary replay and retains the last complete publication until authoritative + replacement. Authority: the architecture's Demand plane/current-demand + contract and “Replay participants and failure,” plus the coverage map's + explicit terminal `failDemand` policy. Nonterminal current-demand recovery + remains a future contract. +- **Boundary repair:** marker truncate synchronously creates fresh include + demand, while established marker reacquisitions start later. The old + `markerReplaying` call-stack flag misclassifies both. The driver now observes + initiating keyed `requestSnapshot` result receipts, matching rejection only + to exact authored root/marker load errors in a dedicated WeakSet. Release + faults cannot supply the allowance. No predicate identity, production replay + state/classifier, or terminal-status observation computes that allowance. +- **Reach:** `replay.keyed-request-failures` constructs both failure placements, + varying head rank, ascending/descending order and marker 3/4 membership before + failure. Only recoverable histories get generated later actions. Each fixed + and random campaign has 40 generated pairs, hence 80 concrete histories; + initiating-keyed failure reaches 40 histories and keyed-reacquisition failure + reaches 40. Reach records actual receipt/acquisition failures, not the selected + placement. A broad-random reach miss (zero initiating failures) was retained + as sampling evidence and repaired constructively, without weakening reach. +- **RED:** original property `replay.ordered-window-faults`, seed `-82695597`, + path `43:2:7:4:4:4:4`, fails `convergence: terminal error`, false versus true, + on unchanged original owner and production. The trace is hold, + truncateMarkers, settle(false), move(root 1, rank 1); scenario is ascending, + offset 0, limit 1, anti-join, on-demand markers, includes, no ties/peer. + The failed oldest load is a fresh include request, so the existing terminal + lazy-demand policy applies. +- **GREEN:** the repaired complete owner passes 31 tests, including broad + fixed 300/random 300 and boundary fixed 40/random 40 campaigns. The original + CI coordinates still replay exactly. Guarded original and new-property + replays each report one executed property; the latter uses seed `20261026`, + path `0:0:0:0`. Named neighbors preserve established include/marker replay + failures and a pending include whose initiating adapter succeeds before + reacquisition fails. +- **Mutant:** private production control makes every `onLoadSubsetError` + terminal via `recordSubsetError(error, true)`. Both boundary campaigns reject + it at the established-replay terminal-error assertion. Fixed seed `20261026`, + path `0:0:0:0`, shrinks to `[[], "asc", 1, false, false]`: exact replay is RED + with the control and GREEN after restoring production. The empty suffix shows + rejection at the intended boundary. Rebuilding every acquisition predicate as + `and(where, where)` passes both campaigns with 40/40 reach, preserving freedom + to reconstruct equivalent expressions. All production controls were restored + byte-for-byte. +- **Limits:** this status relation does not independently establish correct + settlement of the initiating receipt. The pre-existing history-level failure + allowance remains shared in peer scenarios, so per-receiver failure isolation + is unproved. The coverage map names the existing owner and a needed witness + that fails one receiver while checking another at the same cut. These laws + are not retired or claimed closed. Real-provider cancellation, framework + timing, every replay interleaving and nonterminal recovery of failed current + demand remain outside this result. + +Final oracle Git blob: `68c8339969fe4a87380e83e0194fdddc67f0f990`. +Final oracle SHA256: `7a476bec755a566d6eb59bac73dd272c386fa618b4b5aa3d9d00e887d776a66d`. +Original oracle SHA256: +`382f2f2f1975251d77ecaaa16e410fb4048b379c83d4e91834cd630ea6e98ada`. +Unchanged subscription/subscriber SHA256: +`c446932a8a1f8b2efd79d00846f1a110e65a6c6df75d2b24658053e6964b530b` / +`74e97fe498b44446d5ee21912175afab6a3e58d2787e04e890a26771d5c8e1a6`. + +Runtime evidence uses the private source-bound archive at +`/tmp/pr2257-ci-loop/db1-snapshot` and its `db1.config.mts`; coverage/typechecking +are disabled. Full commands and raw log hashes are in +`/tmp/pr2257-ci-loop/db1-evaluation.md`. The two exact guarded commands are: + +```sh +TANSTACK_DB_ORACLE_SEED=-82695597 TANSTACK_DB_ORACLE_PATH=43:2:7:4:4:4:4 TANSTACK_DB_ORACLE_PROPERTY=replay.ordered-window-faults node --import tsx packages/db/tests/oracle-replay.ts --config db1.config.mts +TANSTACK_DB_ORACLE_SEED=20261026 TANSTACK_DB_ORACLE_PATH=0:0:0:0 TANSTACK_DB_ORACLE_PROPERTY=replay.keyed-request-failures node --import tsx packages/db/tests/oracle-replay.ts --config db1.config.mts +``` + +Package ESLint, source/registry Prettier, `pnpm lint:changed` and +`git diff --check` pass. The manifest registers both replay targets and selects +only their replay campaign. No production edit, commit or push is part of this +repair. A fresh law/independence review accepts the final owner blob above; +whole-PR readiness remains with the parent CI evaluation. + +### CF01 guide applicability + +ORC-001/002/003: existing contract authority, independently authored load-error +identity, and the initiating-request observation are explicit beside the law. +ORC-004: constructive prefixes reach both distinctions, with useful pre-failure +variation and recoverable continuations; no full ablation of the inherited +checker grammar was performed or claimed. ORC-005/006: the real Collection and +live-query path reaches the intended comparison, which rejects the incorrect +terminal-error control and accepts equivalent rebuilt predicates. ORC-007: +fixed/random budgets match, and original and new exact replay coordinates are +recorded. ORC-008/009: no new semantic model state is introduced; driver reach +states name request initiation and reacquisition using the existing glossary. +ORC-010: inherited final draining and cleanup remain; the added observer is +restored and its rejection observation is handled. ORC-011 adds no independent +reference computation needing a second formulation; correctness of receipt +settlement and per-receiver error isolation remain explicit unproved limits. +ORC-012 is this source-bound evidence block. ORC-013: fresh and established +request failures, pending success before failure, rebuilt predicates, and the +incorrect terminal-error control distinguish the repaired boundary. ORC-014: +the internal receiving boundary runs through real DB code; the controlled +provider establishes no real-provider, framework or host handoff. ORC-015: +actual receipt/acquisition failures measure both declared states at 40 +histories per campaign. This accounting applies to CF01's bounded observation +repair and does not replace the earlier cleanup-review outcomes. + +The private full/reach and boundary-control runs add only console output of +`failureReach.counts()` to the shared owner; expectations, generated inputs and +production behavior are unchanged. The recorded final owner hashes identify +the shared file without that diagnostic output. diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 201588e470..a43c24796a 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -21,6 +21,7 @@ const staticOracleProperties = [ `query-db.refetch-ordered-prefix`, `ordered-loader.request-authority`, `replay.ordered-window-faults`, + `replay.keyed-request-failures`, `oracle-replay.calibration`, `structural-identity.equal-representations`, `structural-identity.independent-values`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index c19d806728..b31adc8e1d 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -199,6 +199,11 @@ const ownerGroups: ReadonlyArray = [ `ordered-work`, `consumer-parity eager-indexed-window eager-joined-window eager-joined-gap eager-full-join eager-held-load`, ], + [ + `db/tests/query/load-subset-replay-refinement-oracle.test.ts`, + `replay`, + `ordered-window-faults keyed-request-failures`, + ], [ `db/tests/query/pagination-oracle.property.test.ts`, `pagination`, @@ -294,6 +299,10 @@ const replayTestNames = new Map([ ]) function directReplayTestName(property: string): string | undefined { + if (property === `replay.ordered-window-faults`) + return `keeps the ordered window under load and release faults \\(replay campaign\\)` + if (property === `replay.keyed-request-failures`) + return `distinguishes keyed request failure from reacquisition \\(replay campaign\\)` if (property === `cold-join.compound`) return `recomputes generated compound histories, seed=undefined` if (property === `oracle-replay.calibration`) diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index f537b670d3..68c8339969 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -1,6 +1,7 @@ import fc from 'fast-check' import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' +import { CollectionSubscription } from '../../src/collection/subscription.js' import { createDeferred } from '../../src/deferred.js' import { createLiveQueryCollection, @@ -13,6 +14,7 @@ import { BasicIndex } from '../../src/indexes/basic-index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { flushPromises } from '../utils.js' +import { createOracleReach } from '../oracle-reach.js' import { oraclePropertyOptions, oracleRuns, @@ -1095,12 +1097,15 @@ describe(`loadSubset replay refinement`, () => { * convergence does not apply. Either side can be the lazy one: the planner * loads an inner join's root by key when the marker source is eager. A * demand load asks for keys (a `where` with no `orderBy`). A failed load - * that a truncate's replay issued is not fatal: it keeps the last - * publication until the next replay. A load that fails before the query - * first becomes ready is fatal too (an initial load failure), except on + * that reacquires an established request after truncate is not fatal: it + * keeps the last publication until the next replay. A load that fails before + * the query first becomes ready is fatal too (an initial load failure), except on * a lazy join source, which owns its demand. So a query may end in - * `error` only after a demand load issued outside a replay failed, or a - * load failed while the query was still loading. + * `error` only after a newly issued keyed snapshot request failed, or a + * load failed while the query was still loading. A truncate may change the + * private window and issue a new include request while it commits; that + * request still starts fresh lazy demand. Reacquisition of a previously + * settled request is an ordinary replay, even when it starts later. * - **Status:** with no failed load, a quiet live query and its peer are * `ready`; a release that throws does not change status. * - **Reported faults:** an injected fault may surface as a rejected load, a @@ -1124,8 +1129,14 @@ describe(`loadSubset replay refinement`, () => { * * Limits: one ordered source; no full joins (the ordered-work oracle owns * them); includes one level deep; and a provider that reads its rows at the time it - * answers. + * answers. The status check relates an observed initiating request rejection + * to dependent query status; it does not independently establish correct + * settlement of that request receipt. Peer scenarios share one history-level + * failure allowance, so they do not establish receiver-specific fault ownership. */ +type OrderedFailureState = + `initiating-keyed-request-failed` | `keyed-reacquisition-failed` + type OrderedRow = { id: number; rank: number } type OrderedCommand = | { type: `move`; id: number; rank: number } @@ -1260,6 +1271,7 @@ const ordinaryOrderedScenario: OrderedScenario = { async function checkOrderedReplay( history: Array, scenario: OrderedScenario = ordinaryOrderedScenario, + recordFailureReach?: (state: OrderedFailureState) => void, ): Promise { const sign = scenario.direction === `asc` ? 1 : -1 let window = { offset: scenario.offset, limit: scenario.limit } @@ -1341,12 +1353,10 @@ async function checkOrderedReplay( let throwNextUnload = false // Window moves whose settlement waits on held loads. const pendingMoves: Array> = [] - // The truncate starts its replay while it commits. + // Release-fault reach still distinguishes a throw while truncate commits. let truncating = false let anyLoadFailed = false let earlyThrowSinceTruncate = false - // A marker truncate's commit starts its replay loads synchronously. - let markerReplaying = false let demandFailed = false // A failed initial load is fatal too (error 229). The queries are created // below; a load can only fail after they start. @@ -1354,9 +1364,38 @@ async function checkOrderedReplay( let failedWhileLoading = false // Adapter callbacks set these flags, so read them through a call. const fatalLoadFailed = () => demandFailed || failedWhileLoading - // Lazy join demand asks for keys; ordered and full-source loads do not. - const isDemand = (options: LoadSubsetOptions) => - options.where !== undefined && options.orderBy === undefined + // Observe the initiating snapshot request's result. A new lazy keyed + // request may start while truncate commits, whereas reacquisition of an + // established request does not issue another snapshot request. The receipt + // connects an injected adapter failure to its initiating request without + // using replay state, callback timing, or predicate identity. + const injectedLoadFaults = new WeakSet() + // Exact acquisition receipts are used only to measure the two failure + // premises. This requires no referential stability of predicate expressions. + const snapshotAcquisitions = new WeakSet() + const requestSnapshot = CollectionSubscription.prototype.requestSnapshot + const snapshotRequests = vi + .spyOn(CollectionSubscription.prototype, `requestSnapshot`) + .mockImplementation(function (this: CollectionSubscription, options) { + if (options?.where === undefined || options.orderBy !== undefined) { + return requestSnapshot.call(this, options) + } + return requestSnapshot.call(this, { + ...options, + onLoadSubsetResult: (result, acquisition, release) => { + snapshotAcquisitions.add(acquisition) + if (result instanceof Promise) { + void result.catch((error: unknown) => { + if (error instanceof Error && injectedLoadFaults.has(error)) { + demandFailed = true + recordFailureReach?.(`initiating-keyed-request-failed`) + } + }) + } + options.onLoadSubsetResult?.(result, acquisition, release) + }, + }) + }) let violation: string | undefined // The faults this history injects. They may surface as rejected loads, as // thrown releases, or as errors the subscription rethrows from a microtask, @@ -1370,6 +1409,17 @@ async function checkOrderedReplay( injected.add(error) return error } + const injectLoad = (error: Error, options: LoadSubsetOptions) => { + injectedLoadFaults.add(error) + if ( + options.where !== undefined && + options.orderBy === undefined && + !snapshotAcquisitions.has(options) + ) { + recordFailureReach?.(`keyed-reacquisition-failed`) + } + return inject(error) + } let escaped: unknown const escapedError = () => escaped // Adapter callbacks set these flags, so read them through a call. @@ -1430,7 +1480,6 @@ async function checkOrderedReplay( const load: Load = { epoch: truncates, outcome: `pending` } loads.push(load) reach.markerLoads++ - const demand = !markerReplaying && isDemand(options) if (holding) { const ok = await new Promise((resolve) => held.push(resolve), @@ -1438,12 +1487,11 @@ async function checkOrderedReplay( if (!ok) { load.outcome = `failed` anyLoadFailed = true - if (demand) demandFailed = true if (queries.some(({ status }) => status === `loading`)) { failedWhileLoading = true } reach.failedLoads++ - throw inject(new Error(`marker load failed`)) + throw injectLoad(new Error(`marker load failed`), options) } } else await Promise.resolve() if (options.signal?.aborted) { @@ -1494,7 +1542,6 @@ async function checkOrderedReplay( acquired.set(options, (acquired.get(options) ?? 0) + 1) const load: Load = { epoch: truncates, outcome: `pending` } loads.push(load) - const demand = !truncating && isDemand(options) if (holding) { const ok = await new Promise((resolve) => held.push(resolve), @@ -1502,12 +1549,11 @@ async function checkOrderedReplay( if (!ok) { load.outcome = `failed` anyLoadFailed = true - if (demand) demandFailed = true if (queries.some(({ status }) => status === `loading`)) { failedWhileLoading = true } reach.failedLoads++ - throw inject(new Error(`load failed`)) + throw injectLoad(new Error(`load failed`), options) } } else await Promise.resolve() if (options.signal?.aborted) { @@ -1688,13 +1734,7 @@ async function checkOrderedReplay( installedMarkers.add(id) } } - markerReplaying = true - let receipt: true | Promise - try { - receipt = markerSync.commit() - } finally { - markerReplaying = false - } + const receipt = markerSync.commit() if (receipt !== true) await receipt } try { @@ -1886,6 +1926,7 @@ async function checkOrderedReplay( await flushPromises() process.off(`unhandledRejection`, swallow) microtasks.mockRestore() + snapshotRequests.mockRestore() } return reach } @@ -1899,6 +1940,14 @@ const orderedReplayCampaigns = ? ([`replay`] as const) : ([] as const) +const orderedFailureProperty = `replay.keyed-request-failures` +const orderedFailureCampaigns = + orderedReplayConfig.replayPath === undefined + ? ([`fixed`, `random`] as const) + : orderedReplayConfig.replayProperty === orderedFailureProperty + ? ([`replay`] as const) + : ([] as const) + describe(`Ordered windows across truncate replays`, () => { // Traces the campaigns found, kept as replays. Each failed before its fix: // - an unload that throws while a successful repair retires an older @@ -2081,6 +2130,77 @@ describe(`Ordered windows across truncate replays`, () => { markerMode: `on-demand`, }, }, + { + name: `a fresh include request that fails during marker truncate`, + history: [ + { type: `hold` }, + { type: `truncateMarkers` }, + { type: `settle`, ok: false }, + { type: `move`, id: 1, rank: 1 }, + ], + scenario: { + ...ordinaryOrderedScenario, + limit: 1, + joined: `none`, + markerMode: `on-demand`, + includes: true, + }, + reaches: `terminalErrors`, + }, + { + name: `an established include replay that fails`, + history: [ + { type: `hold` }, + { type: `truncateMarkers` }, + { type: `settle`, ok: false }, + { type: `move`, id: 1, rank: 1 }, + ], + scenario: { + ...ordinaryOrderedScenario, + limit: 1, + markerMode: `on-demand`, + includes: true, + }, + reaches: `failedLoads`, + }, + { + name: `an established marker replay after a fresh include succeeds`, + history: [ + { type: `hold` }, + { type: `truncateMarkers` }, + { type: `settle`, ok: true }, + { type: `settle`, ok: false }, + { type: `move`, id: 1, rank: 1 }, + ], + scenario: { + ...ordinaryOrderedScenario, + limit: 1, + joined: `none`, + markerMode: `on-demand`, + includes: true, + }, + reaches: `failedLoads`, + }, + { + name: `a pending include whose initiating load succeeds before replay fails`, + history: [ + { type: `hold` }, + { type: `mark`, id: 1, on: false }, + { type: `truncateMarkers` }, + { type: `settle`, ok: true }, + { type: `settle`, ok: true }, + { type: `settle`, ok: true }, + { type: `settle`, ok: false }, + ], + scenario: { + ...ordinaryOrderedScenario, + limit: 1, + joined: `none`, + markerMode: `on-demand`, + includes: true, + }, + reaches: `failedLoads`, + }, ] satisfies Array<{ name: string history: Array @@ -2151,4 +2271,70 @@ describe(`Ordered windows across truncate replays`, () => { }, 120_000, ) + + // The broad grammar may miss the low-frequency interaction between a + // truncate's private window change and fresh child demand. These generated + // histories vary the marked head's rank and two other markers before + // placing failure on either side of that boundary. Reacquisition failures + // additionally explore later rank, marker, window, replay, release, and + // settlement actions before recovery; terminal histories need no suffix. + // A new include fails in the first prefix. In the second it succeeds, and + // failure instead reaches the already established marker reacquisition. + it.each(orderedFailureCampaigns)( + `distinguishes keyed request failure from reacquisition (%s campaign)`, + async (campaign) => { + const failureReach = createOracleReach( + orderedFailureProperty, + [`initiating-keyed-request-failed`, `keyed-reacquisition-failed`], + ) + await fc.assert( + fc.asyncProperty( + fc.array(orderedCommand, { minLength: 0, maxLength: 6 }), + fc.constantFrom(`asc` as const, `desc` as const), + fc.integer({ min: 1, max: 9 }), + fc.boolean(), + fc.boolean(), + async (suffix, direction, rank, markThird, markFourth) => { + for (const fresh of [true, false]) { + failureReach.beginHistory() + const history: Array = [ + { + type: `move`, + id: direction === `asc` ? 1 : 5, + rank: direction === `asc` ? -rank : 100 + rank, + }, + { type: `mark`, id: 3, on: markThird }, + { type: `mark`, id: 4, on: markFourth }, + { type: `hold` }, + { type: `truncateMarkers` }, + ...(fresh ? [] : [{ type: `settle` as const, ok: true }]), + { type: `settle`, ok: false }, + ...(fresh ? [] : suffix), + ] + const reach = await checkOrderedReplay( + history, + { + ...ordinaryOrderedScenario, + direction, + limit: 1, + joined: `none`, + markerMode: `on-demand`, + includes: true, + }, + failureReach.reach, + ) + if (fresh) { + expect(reach.terminalErrors, `fresh request failure`).toBe(1) + } + } + }, + ), + campaign === `fixed` + ? { seed: 20261026, numRuns: oracleRuns(40) } + : oraclePropertyOptions(40, orderedFailureProperty), + ) + failureReach.assertReached() + }, + 120_000, + ) }) From 2e1a9bd35d7506a5285662d41a186f38935ffb04 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 15:22:07 -0600 Subject: [PATCH 11/12] refactor(electric): release private streams without callback handle state --- .../oracle-reviews/2026-10-26-pr-2257.md | 105 ++++++++++++++++++ .../electric-db-collection/src/electric.ts | 16 +-- ...ectric-descriptor-isolation-oracle.test.ts | 35 +++--- .../tests/electric-oracle.property.test.ts | 42 ++++++- .../tests/electric.test.ts | 23 +++- 5 files changed, 193 insertions(+), 28 deletions(-) diff --git a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md index 5e1321b4b9..be685f9aee 100644 --- a/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md +++ b/docs/contributing/oracle-reviews/2026-10-26-pr-2257.md @@ -942,3 +942,108 @@ The private full/reach and boundary-control runs add only console output of `failureReach.counts()` to the shared owner; expectations, generated inputs and production behavior are unchanged. The recorded final owner hashes identify the shared file without that diagnostic output. + + +## Production reduction after `42e2d9ca8` + +The user requested less production code and fewer shipped bytes. This follow-up +removes the mutable callback-release handle and its no-op initial value. Each +of the four terminal paths now calls the private stream's `unsubscribeAll()` +before the existing abort. Cleanup no longer calls both release methods. +Earlier release at terminal resume/durability failure is permitted by the +accepted cleanup contract; this change introduces no new deadline or law. +The approved release-before-replacement deadline remains unchanged. + +The final production source is three lines and 149 bytes smaller than +merge-base `5649a07d4`. With dependencies external, the production comparison +reports **+1 byte gzip and -5 bytes brotli** against that base (gzip 10,162 to +10,163 bytes). The prior pushed implementation measured +9 gzip/+15 brotli. +The clean direct-call implementation was smaller than either a stop helper or +a release-only alias. Inlining the queued-batch temporary increased brotli +size and was not retained. This is not a claim of reduction in both formats. + +### Law and evidence + +- **Law:** cleanup releases resources owned by its sync run, under `CleanupFn` + and the collection-options creator guide. The installed-SDK source-release + and rotation owners retain their earlier laws, histories, observations and + sensitivity evidence. Removing the returned handle is valid because each + Electric provider session privately owns its stream; full release removes + that stream's callback as well as host listeners. Abort still owns request + cancellation. SDK subscription registration precedes synchronous delivery, + so full release also works before `subscribe()` returns its handle. +- **Reach:** no new product law, history dimension or scope claim is added. + Existing fixed/random campaigns and declared reach checks pass in the full + Electric package. The fixture calibration enumerates both release cuts: + during synchronous subscription and after its return. +- **RED:** a wrapper that only assigns the callback release after + `mockSubscribe()` returns fails the synchronous calibration: expected one + release, observed zero. It passes the after-return neighbor. This is an + assertion failure at callback release, not a setup or timeout result. +- **GREEN:** `pnpm --dir packages/electric-db-collection exec vitest run + --coverage.enabled=false --maxWorkers=2` passes 30 files, 1,136 runtime tests, + one skipped test, and typechecking on the exact source identities below. + The calibration's two cases pass with the wrapper's stopped flag. + Package ESLint has zero errors (23 existing warnings), Prettier, + `pnpm lint:changed` and whitespace checks pass. +- **Mutant:** the calibration rejects the missing-stopped-flag wrapper above. + Earlier installed-SDK listener, release-order, demand-settlement and retired + storage controls remain applicable; their results are recorded above rather + than presented as a new production defect. +- **Limits:** the descriptor fixture deliberately retains a raw callback to + deliver late responses. Its release-call observation does not establish that + the callback became unreachable. The installed-SDK owner remains the + receiving witness for actual SDK release. Native execution, every SDK + version, arbitrary overlapping rotations, real SQLite peer claims, and the + separate core receipt/isolation limits remain unproved. + +The two shared mock streams now create separate callback ownership per instance. +Prototype inheritance keeps request and resume controls live. Full release is +idempotent and handles synchronous release during subscription. Scheduler +restoration reinstates the same wrapper. The descriptor fixture rejects pending +snapshots on abort, matching the SDK responsibility boundary, instead of +making callback release cancel HTTP work. Its late-delivery control is named +`ignoreAbort` to match that premise. + +An independent reviewer checked the production reduction and fixture changes. +It identified the synchronous wrapper gap above. After its repair, the reviewer +accepted the instance ownership, live controls, scheduler restoration, +signal-owned cancellation and explicit raw-callback limit. No further supported +finding remained within that scope. The optional queue inlining was then +reverted to the original source. This is a bounded review of the reduction, +not a new whole-PR readiness verdict. + +ORC-001/002/003 retain the accepted law, independent model and nearby explanations. +ORC-004/007/008/009 retain the existing grammar, paired campaigns, model state +and vocabulary. ORC-005/006/013 add the two bounded fixture cuts and the +wrong-wrapper assertion failure. ORC-010 retains cleanup and failure handling. +ORC-011 has no new shared semantic-fault hypothesis. ORC-014 keeps the mock +release premise separate from the unchanged installed-SDK receiving witness. +ORC-015 retains existing declared product reach, with no expanded coverage +claim. ORC-012 is this source-bound append-only record. + +Final source identities (Git blobs): + +| Source | Git blob | +| --- | --- | +| `packages/electric-db-collection/src/electric.ts` | `1db20a316ae255ea6c479838bd2d45439286faf5` | +| `packages/electric-db-collection/tests/electric.test.ts` | `62a858e17e2a0404fe8cfe2e2d499ce207f421fb` | +| `packages/electric-db-collection/tests/electric-oracle.property.test.ts` | `81fb0f2f45ab089f3a0e5d16f041e0fb1dc57bab` | +| `packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts` | `c1c4bbd4717a208b82046bf0251e5cbd923f401b` | + +Raw local evidence is retained under `/tmp/pr2257-ci-loop/`: + +| Log | SHA256 | +| --- | --- | +| `net-negative-reentrant-fixture-red.log` | `fd5decfa4ac4979d8a23dd4de4bcba87946b8a5bb981c8af3af52043c0d7a972` | +| `net-negative-exact-final-green.log` | `d4c7aa80b57adc70959eb20a59360b30b23480bad42b8234e2457a04adf6eb4c` | +| `net-negative-direct.log` | `3fe347113cbd673a169a470f3e950cd67d8c6f6bdc8adfa1d88462af2a4068bd` | +| `net-negative-lint-changed.log` | `89d7da780deb04850db632f478bc4155d23659849c0e5bec6d114c74dc9d5168` | +| `net-negative-eslint-final.log` | `2540c43d9881aa2984d5fb39cd006db954c7dd0083eb841b17d032713273a12d` | + +Reproduce the bounded calibration with: + +```sh +pnpm --dir packages/electric-db-collection exec vitest run tests/electric-oracle.property.test.ts --coverage.enabled=false -t 'retires its mock callback' +pnpm size:compare --packages electric-db-collection --no-scenarios --rebuild +``` diff --git a/packages/electric-db-collection/src/electric.ts b/packages/electric-db-collection/src/electric.ts index df8a804a3e..1db20a316a 100644 --- a/packages/electric-db-collection/src/electric.ts +++ b/packages/electric-db-collection/src/electric.ts @@ -2302,7 +2302,6 @@ function createElectricSync>( : undefined) let areResumeKeysReady = !resumeKeysPromise const pendingResumeBatches: Array>> = [] - let unsubscribeStream: () => void = () => {} const invalidateResume = () => { resumeInvalid = true @@ -2317,7 +2316,7 @@ function createElectricSync>( newSnapshots.length = 0 commitResetResumeMetadataImmediately() streamErrorVersion++ - unsubscribeStream() + stream.unsubscribeAll() abortController.abort() markError( new Error( @@ -2651,7 +2650,7 @@ function createElectricSync>( return } streamErrorVersion++ - unsubscribeStream() + stream.unsubscribeAll() abortController.abort() if (collection.status !== `error`) markError(error) }, @@ -2698,7 +2697,7 @@ function createElectricSync>( } } - unsubscribeStream = stream.subscribe( + stream.subscribe( (messages: Array>) => { if (!areResumeKeysReady) { pendingResumeBatches.push([...messages]) @@ -2738,7 +2737,7 @@ function createElectricSync>( rejectFullSnapshot(error) commitResetResumeMetadataImmediately() streamErrorVersion++ - unsubscribeStream() + stream.unsubscribeAll() abortController.abort() markError(error) }, @@ -2754,14 +2753,7 @@ function createElectricSync>( if (!abortController.signal.aborted && !initialSyncFailedFinally) cleanupAbortedStream = true shapeOptions.signal?.removeEventListener(`abort`, forwardExternalAbort) - // Unsubscribe from the stream - unsubscribeStream() - // Aborting the stream does not remove the visibility listener it - // registered when it was constructed, and that listener keeps the - // stream reachable. The stream is private to this session, so - // unsubscribeAll affects no other consumer. stream.unsubscribeAll() - // Abort the abort controller to stop the stream abortController.abort() if (transactionStarted) { transactionStarted = false diff --git a/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts b/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts index 922936f01e..c1c4bbd471 100644 --- a/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts @@ -131,7 +131,7 @@ type StreamHarness = { send: (messages: Array>) => void unsubscribe: ReturnType holdSnapshots: boolean - ignoreUnsubscribe: boolean + ignoreAbort: boolean pendingSnapshotCount: () => number snapshotRequested: () => Promise completeSnapshot: (messages?: Array>) => void @@ -145,27 +145,36 @@ vi.mock(`@electric-sql/client`, async () => { const actual = await vi.importActual(`@electric-sql/client`) return { ...actual, - ShapeStream: vi.fn((options: { log?: string }) => { + ShapeStream: vi.fn((options: { log?: string; signal?: AbortSignal }) => { const pendingSnapshots: Array<{ resolve: () => void reject: (error: Error) => void }> = [] const requestObservers: Array<() => void> = [] let snapshotRequests = 0 - const unsubscribe = vi.fn(() => { - if (harness?.ignoreUnsubscribe) return - for (const pending of pendingSnapshots.splice(0)) { - pending.reject(new Error(`snapshot aborted`)) - } - }) + const unsubscribe = vi.fn() + // As in the SDK, abort owns snapshot cancellation. Callback release + // alone does not reject an in-flight HTTP request. + options.signal?.addEventListener( + `abort`, + () => { + if (harness?.ignoreAbort) return + for (const pending of pendingSnapshots.splice(0)) { + pending.reject(new Error(`snapshot aborted`)) + } + }, + { once: true }, + ) let harness: StreamHarness | undefined return { subscribe: (send: StreamHarness[`send`]) => { harness = { + // Deliberately retain the raw callback for late-delivery tests. + // Release calls are observed separately from these injections. send, unsubscribe, holdSnapshots: holdNewSnapshots, - ignoreUnsubscribe: false, + ignoreAbort: false, pendingSnapshotCount: () => pendingSnapshots.length, snapshotRequested: () => snapshotRequests > 0 @@ -184,7 +193,7 @@ vi.mock(`@electric-sql/client`, async () => { onStreamSubscribed?.() return unsubscribe }, - unsubscribeAll: vi.fn(), + unsubscribeAll: unsubscribe, // ShapeStream.requestSnapshot is legal only in changes_only mode. // The installed-SDK delivery oracle is the receiving witness. requestSnapshot: vi.fn(() => { @@ -776,7 +785,7 @@ fixedCase( await expect(collection.utils.awaitTxId(778)).resolves.toBe(true) await expect(collection.utils.awaitTxId(779)).resolves.toBe(true) old.holdSnapshots = true - old.ignoreUnsubscribe = true + old.ignoreAbort = true const oldWait = collection.utils.awaitTxId(801, 10_000).then( () => undefined, (error: unknown) => error, @@ -966,7 +975,7 @@ fixedCase(`aborts one demand while its provider session restarts`, async () => { old.send([upToDate]) await collection.stateWhenReady() old.holdSnapshots = true - old.ignoreUnsubscribe = true + old.ignoreAbort = true const released = Promise.resolve( collection._sync.loadSubset({ limit: 1, signal: aborter.signal }), ).then( @@ -1390,7 +1399,7 @@ fixedCase( await atCheckpoint(secondStarted, `second cache rotation starts`) const retiredStream = streams[1]! expect(retiredStream.unsubscribe).toHaveBeenCalledOnce() - retiredStream.ignoreUnsubscribe = true + retiredStream.ignoreAbort = true retiredStream.send([insert(7, `retired`), upToDate]) expect(write).not.toHaveBeenCalled() expect(markError).not.toHaveBeenCalled() diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index 51bef72249..81fb0f2f45 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -185,11 +185,34 @@ function createDeferred(): { return { promise, resolve, reject } } +// Each stream owns its callback release. Prototype inheritance keeps the +// shared request and resume controls live without sharing callback ownership. +function createMockStream() { + let unsubscribe: (() => void) | undefined + let stopped = false + return Object.assign(Object.create(mockStream) as typeof mockStream, { + subscribe: (...args: Parameters) => { + const release: (() => void) | undefined = mockSubscribe(...args) + if (stopped) release?.() + else unsubscribe = release + return release + }, + unsubscribeAll: () => { + mockStream.unsubscribeAll() + if (stopped) return + stopped = true + unsubscribe?.() + }, + }) +} + vi.mock(`@electric-sql/client`, async () => { const actual = await vi.importActual(`@electric-sql/client`) return { ...actual, - ShapeStream: vi.fn(() => mockStream), + // Full SDK release clears this stream's callback, including when another + // Collection has a separate stream using these shared request mocks. + ShapeStream: vi.fn(createMockStream), } }) @@ -2863,7 +2886,7 @@ async function runSchedulerPermutation( } finally { await collection.cleanup() vi.mocked(ShapeStream).mockReset() - vi.mocked(ShapeStream).mockImplementation(() => mockStream as never) + vi.mocked(ShapeStream).mockImplementation(() => createMockStream() as never) } } @@ -2892,6 +2915,21 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { resetElectricOracleMocks() }) + it.each([false, true])( + `retires its mock callback when release occurs during subscribe: %s`, + (duringSubscribe) => { + const stream = createMockStream() + const release = vi.fn() + mockSubscribe.mockImplementationOnce(() => { + if (duringSubscribe) stream.unsubscribeAll() + return release + }) + stream.subscribe(() => {}) + if (!duringSubscribe) stream.unsubscribeAll() + expect(release).toHaveBeenCalledOnce() + }, + ) + it.each([false, true])( `cleans direct traces after delivery failure and preserves the primary error (cleanup failure: %s)`, async (failCleanup) => { diff --git a/packages/electric-db-collection/tests/electric.test.ts b/packages/electric-db-collection/tests/electric.test.ts index 7c30e63c3a..62a858e17e 100644 --- a/packages/electric-db-collection/tests/electric.test.ts +++ b/packages/electric-db-collection/tests/electric.test.ts @@ -53,11 +53,32 @@ const mockStream = { lastOffset: `-1` as string, } +// Each stream owns its callback release. Prototype inheritance keeps the +// shared request and resume controls live without sharing callback ownership. +function createMockStream() { + let unsubscribe: (() => void) | undefined + let stopped = false + return Object.assign(Object.create(mockStream) as typeof mockStream, { + subscribe: (...args: Parameters) => { + const release: (() => void) | undefined = mockSubscribe(...args) + if (stopped) release?.() + else unsubscribe = release + return release + }, + unsubscribeAll: () => { + mockStream.unsubscribeAll() + if (stopped) return + stopped = true + unsubscribe?.() + }, + }) +} + vi.mock(`@electric-sql/client`, async () => { const actual = await vi.importActual(`@electric-sql/client`) return { ...actual, - ShapeStream: vi.fn(() => mockStream), + ShapeStream: vi.fn(createMockStream), } }) From f4a1fdbed014192cdbbf4029e18710f6fad0131d Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 15:42:59 -0600 Subject: [PATCH 12/12] chore: remove passing persistence snippet from failure baseline --- scripts/doc-snippets-baseline.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/doc-snippets-baseline.json b/scripts/doc-snippets-baseline.json index 85b1f8a693..69187088c2 100644 --- a/scripts/doc-snippets-baseline.json +++ b/scripts/doc-snippets-baseline.json @@ -300,7 +300,6 @@ "055353a57302f9c0", "17c01d52be8bc498", "47b9731bbad61297", - "81435553635b5b53", "bfccf26477a6bfd9", "e6b7f1783d1df2ca" ],