From 4fad71b45bb66d4cf1c6002b0ce17d671cc6a0c6 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 10:51:55 -0600 Subject: [PATCH 1/8] feat(oracles): lint oracle files and return reports to coding agents Add an ESLint plugin for oracle files (scripts/eslint-plugin-oracle/) with the oracle/no-disabled rule. It reports `.only`, a `.skip` or `.todo` that switches off a body, `.skipIf`/`.runIf` conditions that name no replay, Playwright's unconditional runtime skips, and generated properties limited to one unpinned run. The rules have no inline exception. scripts/agent-lint-feedback.mjs runs the oracle rules in a pass that ignores inline directives, so an eslint-disable comment cannot silence them. It serves the Claude Code, Codex, Gemini CLI, and Copilot hooks after each edit and at the end of a turn, `pnpm lint:changed`, `pnpm lint:oracles` in CI, and the pre-commit hook. Pre-commit ESLint now runs in chunks to avoid running out of memory on large commits. Remove the skipped random campaign for the #2139 known gap; the fixed-seed `it.fails` cell still pins it, and its replay name is unregistered so a replay request fails loudly. Refs #2234 Co-authored-by: Isaac --- .claude/settings.json | 27 ++ .codex/hooks.json | 27 ++ .gemini/settings.json | 29 +++ .github/hooks/oracle-lint.json | 20 ++ .github/workflows/pr.yml | 4 + AGENTS.md | 14 + CONTRIBUTING.md | 5 + docs/contributing/oracle-coverage.md | 2 +- eslint.config.js | 2 + lint-staged.config.js | 21 ++ package.json | 8 +- .../tests/persisted-oracle.test.ts | 17 +- packages/db/tests/oracle-config.ts | 1 - packages/db/tests/proxy-oracle.test.ts | 2 +- scripts/agent-lint-feedback.mjs | 243 +++++++++++++++++ scripts/eslint-plugin-oracle/index.js | 35 +++ .../eslint-plugin-oracle/no-disabled.test.js | 137 ++++++++++ .../eslint-plugin-oracle/rules/no-disabled.js | 245 ++++++++++++++++++ 18 files changed, 818 insertions(+), 21 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .codex/hooks.json create mode 100644 .gemini/settings.json create mode 100644 .github/hooks/oracle-lint.json create mode 100644 lint-staged.config.js create mode 100644 scripts/agent-lint-feedback.mjs create mode 100644 scripts/eslint-plugin-oracle/index.js create mode 100644 scripts/eslint-plugin-oracle/no-disabled.test.js create mode 100644 scripts/eslint-plugin-oracle/rules/no-disabled.js diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..5883c8c69d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|MultiEdit|Write", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=claude --event=edit", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=claude --event=stop", + "timeout": 120 + } + ] + } + ] + } +} diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000000..7e2f706b19 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,27 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "apply_patch", + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/scripts/agent-lint-feedback.mjs\" --format=codex --event=edit", + "timeout": 30 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$(git rev-parse --show-toplevel)/scripts/agent-lint-feedback.mjs\" --format=codex --event=stop", + "timeout": 120 + } + ] + } + ] + } +} diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000000..c83f4593b0 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,29 @@ +{ + "hooks": { + "AfterTool": [ + { + "matcher": "write_file|replace", + "hooks": [ + { + "type": "command", + "name": "oracle-lint-edit", + "command": "node \"$GEMINI_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=gemini --event=edit", + "timeout": 30000 + } + ] + } + ], + "AfterAgent": [ + { + "hooks": [ + { + "type": "command", + "name": "oracle-lint-turn", + "command": "node \"$GEMINI_PROJECT_DIR/scripts/agent-lint-feedback.mjs\" --format=gemini --event=stop", + "timeout": 120000 + } + ] + } + ] + } +} diff --git a/.github/hooks/oracle-lint.json b/.github/hooks/oracle-lint.json new file mode 100644 index 0000000000..3e4218d927 --- /dev/null +++ b/.github/hooks/oracle-lint.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "hooks": { + "postToolUse": [ + { + "type": "command", + "matcher": "edit|create", + "bash": "node scripts/agent-lint-feedback.mjs --format=copilot --event=edit", + "timeoutSec": 30 + } + ], + "agentStop": [ + { + "type": "command", + "bash": "node scripts/agent-lint-feedback.mjs --format=copilot --event=stop", + "timeoutSec": 120 + } + ] + } +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b5eb9dd20d..6f3de15a97 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -26,6 +26,10 @@ jobs: uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Check lint run: pnpm run lint:check + - name: Check oracle lint without inline directives + run: pnpm run lint:oracles + - name: Test oracle lint rules + run: pnpm run test:lint-rules minified-db: name: Minified DB public API runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 8238e95815..458b57c2aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,20 @@ includes this block: - **Limits:** the states, paths, and observations that the evidence does not cover. +### Lint feedback while you work + +The `oracle/` lint rules in `scripts/eslint-plugin-oracle/` report the +shortcuts that a syntactic check can see. The repository's agent hooks +(`.claude/`, `.codex/`, `.gemini/`, and `.github/hooks/`) return the reports +after each edit and at the end of each turn. A hook sees only the edits that +the harness's own edit tools make. After an edit through a shell command, or +in a harness without these hooks, run `pnpm lint:changed` before you finish. + +Fix a report. Do not turn the rule off. The hooks, `pnpm lint:changed`, the +pre-commit hook, and CI ignore `eslint-disable` comments for these rules. A +true exception needs a maintainer to change the rule in a reviewed pull +request. + ## Required reading: live-query materialization Before reading, analyzing, or modifying correlated live-query materialization diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d34fb357a0..985863341c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,11 @@ If you have been assigned to fix an issue or develop a new feature, please follo pnpm lint ``` + `pnpm lint:changed` lints only the files that your branch changes. If you + work with a coding agent, the repository's hooks return oracle lint reports + to it as it edits. Codex and Gemini CLI run project hooks only after you + trust them in `/hooks`; review them again when they change. + - Implement your changes and tests in the relevant package or example. - For a production change that adds, removes, or changes observable behavior, extend the primary oracle when it can express the governing law. A focused diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 5afacbd874..a085dd75d9 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -638,7 +638,7 @@ generic visitor behavior outside the named collectors. | 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. | | 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 variant is skipped, 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 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. | | Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts), [web connectivity replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts), [review record](oracle-reviews/pr-1879-visible-replay.md) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. The web connectivity owner checks a persisted write under a false browser online hint, then visible replay by event or explicit retry, at provider-call, outbox, and caller-settlement cuts. It uses controlled browser globals and fake storage; it does not prove actual browser network detection, genuine offline retry timing, multi-tab leadership transfer, or React Native behavior. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | | Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared framework contracts](https://github.com/TanStack/db/tree/main/packages/db/tests/conformance), [Vue synchronous publication](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts) | Exact exposed rows/pages and each framework's own lifecycle cuts. Vue's synchronous watcher checks insert, update, and nonterminal delete through supplied Collection and identity query inputs; it does not cover an empty final result, multiple changes in one transaction, or query recompilation. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The shared lifecycle histories distinguish adapter-created projections from caller-owned retention: omitted and explicitly `undefined` `gcTime` use the 1 ms adapter policy; `gcTime: 0`, a positive 100 ms retention, and supplied Collections keep caller authority. They observe retirement after unmount, replacement, disabling, and positive expiry, plus survival before that expiry. The shared suite also checks initial-query readiness over a lagging on-demand source, for a list and a `findOne` query and for the infinite query (React, Vue, and Svelte): not ready before the subset settles, ready at settlement while the source is still `loading`, and still ready after the source becomes ready. Solid's Suspense over that source has its own witness. A separate Solid two-resource Suspense witness holds one ready projection past the 50 ms unsubscribed floor while its sibling remains pending, then checks both commit and boundary abandonment release the temporary preload owner and preserve ordinary 1 ms teardown. | diff --git a/eslint.config.js b/eslint.config.js index 34cfc4c5ef..4004fd1a5d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,4 +1,5 @@ import { tanstackConfig } from '@tanstack/eslint-config' +import oraclePlugin from './scripts/eslint-plugin-oracle/index.js' export default [ ...tanstackConfig, @@ -45,4 +46,5 @@ export default [ 'import/no-cycle': `error`, }, }, + oraclePlugin.configs.recommended, ] diff --git a/lint-staged.config.js b/lint-staged.config.js new file mode 100644 index 0000000000..7741941e3a --- /dev/null +++ b/lint-staged.config.js @@ -0,0 +1,21 @@ +// ESLint ran out of memory on a commit of more than about 100 files, so +// staged files are linted in chunks, one ESLint process each. lint-staged +// splits each command without a shell or escape sequences, so plain double +// quotes keep Windows backslashes intact. +const chunkSize = 40 + +export default { + // The oracle rules, without inline directives. lint-staged appends the + // staged oracle files as arguments. + '*oracle*.{ts,tsx}': `node scripts/agent-lint-feedback.mjs`, + '*.{ts,tsx}': (files) => { + const commands = [] + for (let start = 0; start < files.length; start += chunkSize) { + const chunk = files.slice(start, start + chunkSize) + commands.push( + `eslint --fix ${chunk.map((file) => `"${file}"`).join(` `)}`, + ) + } + return commands + }, +} diff --git a/package.json b/package.json index 6729e5169d..85af542017 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "format": "prettier --experimental-cli --ignore-unknown '**/*' --write", "lint": "pnpm --filter \"./packages/**\" lint", "lint:check": "pnpm --filter \"./packages/**\" lint --fix=false", + "lint:changed": "node scripts/agent-lint-feedback.mjs", + "lint:oracles": "node scripts/agent-lint-feedback.mjs --all", + "test:lint-rules": "node --test \"scripts/eslint-plugin-oracle/*.test.js\"", "lint-all": "eslint . --fix", "prepare": "husky", "test": "pnpm --filter \"./packages/**\" test && pnpm --filter @tanstack/db test:indexed-db:package", @@ -67,10 +70,5 @@ "vite": "^7.3.0", "vitest": "^3.2.4", "zod": "^3.25.76" - }, - "lint-staged": { - "*.{ts,tsx}": [ - "eslint --fix" - ] } } diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index c4304f0de3..647a9520e4 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -215,7 +215,6 @@ const describeUnlessOracleReplay = requestedOracleReplayProperty === undefined || requestedOracleReplayProperty === `persistence.retained-demand` || requestedOracleReplayProperty === `persistence.generation-notice` || - requestedOracleReplayProperty === `persistence.follower-generation-notice` || requestedOracleReplayProperty === `persistence.follower-remote-demand` ? describe : describe.skip @@ -26043,7 +26042,10 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), }) // Known gap: see #2139. A rotated follower stays in scoped recovery, so - // the owner's rows in the new generation never become visible. + // the owner's rows in the new generation never become visible. Some + // generated histories pass today, so a random draw cannot be pinned as + // failing; this fixed seed pins the gap. The fix for #2139 adds the random + // and replayed campaign back. it.fails( `checks generated follower generation-notice histories (fixed)`, () => @@ -26055,17 +26057,6 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { { seed: 2128, numRuns: oracleRuns(12) }, ), ) - // Known gap: see #2139. Some generated histories pass today, so a random - // draw cannot be pinned as failing; the fixed seed above pins the gap. - it.skip(`checks generated follower generation-notice histories (random or replayed)`, () => - fc.assert( - fc.asyncProperty( - followerGenerationNoticeHistory, - assertFollowerGenerationNoticeHistory, - ), - oraclePropertyOptions(12, `persistence.follower-generation-notice`), - )) - /** * A follower routes demand it cannot serve to the elected owner. The owner * loads the rows upstream, commits them durably, and broadcasts the commit diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 8fe37b9d2e..dffda0f111 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -46,7 +46,6 @@ const staticOracleProperties = [ `sqlite-reload.class`, `sqlite-reload.class.electron`, `persistence.generation-notice`, - `persistence.follower-generation-notice`, `persistence.follower-remote-demand`, `sqlite-ordinary.write-history`, `sqlite-ordinary.independent-work`, diff --git a/packages/db/tests/proxy-oracle.test.ts b/packages/db/tests/proxy-oracle.test.ts index db57e3901a..a6135a77dc 100644 --- a/packages/db/tests/proxy-oracle.test.ts +++ b/packages/db/tests/proxy-oracle.test.ts @@ -1970,7 +1970,7 @@ describe(`Proxy Library`, () => { delete proxy.arr[2] expect(getChanges()).toEqual({ - // eslint-disable-next-line + // eslint-disable-next-line no-sparse-arrays -- the deleted index is a hole arr: [1, 2, , 4, 5], }) expect(2 in arr).toBe(true) diff --git a/scripts/agent-lint-feedback.mjs b/scripts/agent-lint-feedback.mjs new file mode 100644 index 0000000000..635cfece92 --- /dev/null +++ b/scripts/agent-lint-feedback.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +/** + * Returns oracle lint reports to a coding agent while it works. + * + * AGENTS.md forbids the shortcuts that make an oracle pass without enforcing + * its law. Prose did not stop them, because nothing made a shortcut visible + * while the agent was still working. Each agent harness can run a command + * after an edit and at the end of a turn, and can return the command's output + * to the model. This script is that command for every harness: a thin + * adapter per harness reads its hook input and writes its response format. + * + * --format=claude|codex|gemini|copilot the harness that runs the hook + * --event=edit after an edit: lint the edited files + * --event=stop at the end of a turn: lint every file the branch changed + * --format=text print the reports and exit 1: `pnpm lint:changed` lints + * the changed files, `pnpm lint:oracles` (`--all`) every + * oracle file, and the pre-commit hook the staged files it + * passes as arguments + * + * Every mode runs only the `oracle/` rules, and ignores inline directives. + * The rules have no inline exception, and ESLint cannot protect one rule from + * an `eslint-disable` comment, so this pass is where they are enforced. The + * root ESLint config also runs them, so editors show them, but a directive can + * silence them there. The rules need no type information, so a typical file + * takes tens of milliseconds, and the largest about a second. The repository + * config builds a TypeScript program and takes several seconds per file, + * which is too slow for every edit; `pnpm lint`, the pre-commit hook, and CI + * run it. + * + * An edit hook sees only the harness's own edit tools. An edit made through a + * shell command reaches the model at the end of the turn, when the stop hook + * lints every changed file. When a harness's hook input names no file that + * this script can find, the edit hook lints every changed file as well. + */ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { isAbsolute, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' +import tsParser from '@typescript-eslint/parser' +import { ESLint } from 'eslint' +import oraclePlugin, { oracleFiles } from './eslint-plugin-oracle/index.js' + +const root = fileURLToPath(new URL(`../`, import.meta.url)) + +const { values: options, positionals } = parseArgs({ + options: { + format: { type: `string`, default: `text` }, + event: { type: `string`, default: `edit` }, + all: { type: `boolean`, default: false }, + }, + allowPositionals: true, +}) + +const formats = new Set([`claude`, `codex`, `gemini`, `copilot`, `text`]) +if (!formats.has(options.format)) { + throw new Error(`--format must be one of ${[...formats].join(`, `)}`) +} +if (![`edit`, `stop`].includes(options.event)) { + throw new Error(`--event must be edit or stop`) +} +const isHook = options.format !== `text` + +/** Every file that the branch adds or changes, including uncommitted work. */ +function changedFiles() { + const git = (...args) => + execFileSync(`git`, args, { cwd: root, encoding: `utf8` }).trim() + let base = `HEAD` + try { + base = git(`merge-base`, `HEAD`, `origin/main`) + } catch { + // Without origin/main, the uncommitted work is the change. + } + return [ + git(`diff`, `--name-only`, `--diff-filter=ACMR`, base), + git(`ls-files`, `--others`, `--exclude-standard`), + ] + .flatMap((output) => output.split(`\n`)) + .filter(Boolean) + .map((file) => join(root, file)) +} + +const pathKeys = /^(file_?path|filePath|path|absolute_path|target_file)$/i +const patchHeader = /^\*\*\* (?:Add File|Update File|Move to): (.+)$/gm + +/** + * The files that a hook input names. Harnesses put the path in different + * fields, and Codex sends the text of an `apply_patch` call instead, so this + * searches the whole input for path fields and patch headers. + */ +function editedFiles(input) { + const base = typeof input.cwd === `string` ? input.cwd : root + const found = new Set() + const visit = (value, key) => { + if (typeof value === `string`) { + if (key !== undefined && pathKeys.test(key)) found.add(value) + for (const match of value.matchAll(patchHeader)) + found.add(match[1].trim()) + // Copilot's tool arguments can arrive as a JSON string. + if (/^\s*\{/.test(value)) { + try { + visit(JSON.parse(value)) + } catch { + // Not JSON; already searched as text. + } + } + } else if (Array.isArray(value)) { + for (const item of value) visit(item) + } else if (value !== null && typeof value === `object`) { + for (const [childKey, child] of Object.entries(value)) + visit(child, childKey) + } + } + visit(input.tool_input ?? input.toolArgs ?? input) + return [...found].map((file) => + isAbsolute(file) ? file : resolve(base, file), + ) +} + +function readHookInput() { + if (!isHook) return {} + const text = readFileSync(0, `utf8`) + return text.trim() === `` ? {} : JSON.parse(text) +} + +function createLinter() { + return new ESLint({ + cwd: root, + overrideConfigFile: true, + overrideConfig: [ + { ignores: [`**/dist/**`] }, + { files: [`**/*.ts`, `**/*.tsx`], languageOptions: { parser: tsParser } }, + { linterOptions: { noInlineConfig: true } }, + oraclePlugin.configs.recommended, + ], + }) +} + +/** Lints the oracle files among `files`, or every oracle file without them. */ +async function lint(eslint, files) { + const lintable = [] + for (const file of new Set(files ?? [])) { + if (!existsSync(file) || relative(root, file).startsWith(`..`)) continue + if (!/oracle[^/]*\.tsx?$/.test(file)) continue + if (await eslint.isPathIgnored(file)) continue + lintable.push(file) + } + if (files !== undefined && lintable.length === 0) return [] + const results = await eslint.lintFiles( + files === undefined ? oracleFiles : lintable, + ) + // Keep oracle reports and files that cannot be parsed. This drops the + // notices that the ignored directives have no effect. + return results.map((result) => ({ + ...result, + messages: result.messages.filter( + (message) => message.fatal || message.ruleId?.startsWith(`oracle/`), + ), + })) +} + +function reportText(results) { + const lines = [] + for (const result of results) { + for (const message of result.messages) { + if (message.severity < 2) continue + lines.push( + `${relative(root, result.filePath)}:${message.line}:${message.column} ` + + `${message.message} (${message.ruleId ?? `parse`})`, + ) + } + } + return lines.join(`\n`) +} + +/** + * Writes the report in the harness's format. A post-edit report reaches the + * model beside the tool result. A stop report makes the agent take another + * turn, once: when the harness says a stop hook already continued this turn, + * the report is left for the pre-commit hook and CI, so a report the agent + * cannot fix does not loop. + */ +function respond(report, input, scope) { + const { format, event } = options + if (!isHook) { + if (report !== ``) { + process.stderr.write(`${report}\n`) + process.exitCode = 1 + } + return + } + const header = + scope === `edited` + ? `Oracle lint reports in the file you edited:` + : `Oracle lint reports in this branch's changed files. Fix them before finishing:` + const reason = `${header}\n${report}\nSee "How oracle work is scored" in AGENTS.md.` + if (report === `` || (event === `stop` && input.stop_hook_active === true)) { + // Codex requires JSON on stdout when a stop hook exits 0. + if (format === `codex` && event === `stop`) process.stdout.write(`{}\n`) + return + } + if (format === `claude` || format === `codex`) { + // Exit code 2 returns stderr to the model in both harnesses. + process.stderr.write(`${reason}\n`) + process.exitCode = 2 + return + } + if (format === `gemini`) { + const output = + event === `stop` + ? { decision: `deny`, reason } + : { + hookSpecificOutput: { + hookEventName: `AfterTool`, + additionalContext: reason, + }, + } + process.stdout.write(`${JSON.stringify(output)}\n`) + return + } + // Copilot treats a nonzero exit as a warning, so it needs JSON on stdout. + const output = + event === `stop` + ? { decision: `block`, reason } + : { additionalContext: reason } + process.stdout.write(`${JSON.stringify(output)}\n`) +} + +/** The files to lint: named on the command line, edited, or changed. */ +function selectFiles(input) { + if (options.all) return { scope: `all`, files: undefined } + if (positionals.length > 0) { + return { scope: `named`, files: positionals.map((file) => resolve(file)) } + } + const edited = options.event === `edit` && isHook ? editedFiles(input) : [] + return edited.length > 0 + ? { scope: `edited`, files: edited } + : { scope: `changed`, files: changedFiles() } +} + +const input = readHookInput() +const { scope, files } = selectFiles(input) +respond(reportText(await lint(createLinter(), files)), input, scope) diff --git a/scripts/eslint-plugin-oracle/index.js b/scripts/eslint-plugin-oracle/index.js new file mode 100644 index 0000000000..9588d8d324 --- /dev/null +++ b/scripts/eslint-plugin-oracle/index.js @@ -0,0 +1,35 @@ +import noDisabled from './rules/no-disabled.js' + +/** + * Lint rules for oracle files. + * + * AGENTS.md lists the shortcuts that make an oracle pass without enforcing + * its law ("How oracle work is scored"). These rules report the ones that a + * syntactic check can see, in files whose names contain `oracle`. They need no + * type information, so an agent's post-edit hook can run them on one file in + * well under a second. A directive can silence them in the root config, so + * scripts/agent-lint-feedback.mjs enforces them in a pass that ignores inline + * directives. + */ + +export const oracleFiles = [`**/*oracle*.ts`, `**/*oracle*.tsx`] + +const plugin = { + meta: { name: `eslint-plugin-oracle` }, + rules: { + 'no-disabled': noDisabled, + }, +} + +plugin.configs = { + recommended: { + name: `oracle/recommended`, + files: oracleFiles, + plugins: { oracle: plugin }, + rules: { + 'oracle/no-disabled': `error`, + }, + }, +} + +export default plugin diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js new file mode 100644 index 0000000000..0e8ca8017b --- /dev/null +++ b/scripts/eslint-plugin-oracle/no-disabled.test.js @@ -0,0 +1,137 @@ +import { describe, it } from 'node:test' +import { RuleTester } from 'eslint' +import tsParser from '@typescript-eslint/parser' +import noDisabled from './rules/no-disabled.js' + +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester({ + languageOptions: { parser: tsParser }, +}) + +ruleTester.run(`oracle/no-disabled`, noDisabled, { + valid: [ + // A replay that does not select this property registers an empty test. + `it.skip('runs only when its replay property is selected', () => {})`, + `it.todo('loads every subset from one generation')`, + // Playwright's runtime skip, conditioned on the environment. + `test.skip(browserName !== 'chromium', 'Chromium-only control')`, + `test.skip(isMobile)`, + `test.skip(false, 'kept for a later browser')`, + // A replay selection may switch a test off. + `it.skipIf(replayRequested)('checks the history', () => check())`, + `it.runIf(replay.replayPath === undefined)('checks the history', () => check())`, + `fc.assert(property, oraclePropertyOptions(12, 'cleanup-queue.history'))`, + `fc.assert(property, { numRuns: oracleRuns(20) })`, + // A pinned replay or mutant witness runs one fixed history. + `fc.check(property, { seed: 205203, numRuns: 1 })`, + `fc.check(property, { seed: fixedSeed, numRuns: 1 })`, + `fc.assert(property, { seed: 2128, numRuns: oracleRuns(1) })`, + `fc.assert(property, replayRequested ? { numRuns: oracleRuns(1) } : {})`, + `fc.assert(property, campaign === 'replay' ? { numRuns: 1 } : {})`, + `fc.assert(property, options.path === undefined ? options : { ...options, numRuns: 1 })`, + // An expectation about a recorded run is not a run option. + `expect(report).toMatchObject({ numRuns: 1, failed: true })`, + // A known gap is pinned as failing on a fixed seed, not skipped. + `it.fails('checks the fixed history', () => fc.assert(property, { seed: 2128, numRuns: oracleRuns(12) }))`, + ], + invalid: [ + { + code: `it.only('checks the history', () => {})`, + errors: [{ messageId: `only` }], + }, + { + code: `describe.only('cleanup queue', () => { it('a', () => {}) })`, + errors: [{ messageId: `only` }], + }, + { + code: `fcTest.only.prop([history])('checks the history', (h) => check(h))`, + errors: [{ messageId: `only` }], + }, + { + code: `it.skip('checks random histories', () => fc.assert(property))`, + errors: [{ messageId: `skip`, data: { name: `skip` } }], + }, + { + code: `describe.skip('cleanup queue', () => { it('a', () => check()) })`, + errors: [{ messageId: `skip` }], + }, + { + code: `fcTest.skip.prop([history])('checks the history', (h) => check(h))`, + errors: [{ messageId: `skip` }], + }, + { + code: `it.skip('checks follower histories', assertFollowerHistory)`, + errors: [{ messageId: `skip` }], + }, + { + code: `fcTest.skip.prop([history])('checks the history', checkHistory)`, + errors: [{ messageId: `skip` }], + }, + { + code: `test.skip()`, + errors: [{ messageId: `skip` }], + }, + { + code: `test.skip(true, 'flaky in CI')`, + errors: [{ messageId: `skip` }], + }, + { + code: `it.skipIf(true)('checks the history', () => check())`, + errors: [{ messageId: `conditional`, data: { name: `skipIf` } }], + }, + { + code: `describe.runIf(false)('cleanup queue', () => { it('a', () => check()) })`, + errors: [{ messageId: `conditional`, data: { name: `runIf` } }], + }, + { + code: `describe.skipIf(process.env.CI)('cleanup queue', () => { it('a', () => check()) })`, + errors: [{ messageId: `conditional` }], + }, + { + code: `it.todo('checks the history', () => { check() })`, + errors: [{ messageId: `skip`, data: { name: `todo` } }], + }, + { + code: `fc.assert(property, { numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, { ...oraclePropertyOptions(100, 'p'), numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, oraclePropertyOptions(1, 'cleanup-queue.history'))`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, { seed: Date.now(), numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, { seed: undefined, numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, filePath ? { numRuns: 1 } : {})`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `it.skipIf(useFilePath)('checks the history', () => check())`, + errors: [{ messageId: `conditional` }], + }, + { + code: `fc.assert(property, { numRuns: oracleRuns(0) })`, + errors: [{ messageId: `singleRun` }], + }, + { + // A comment does not grant an exception. + code: ` + // oracle-exclusion: known gap + it.skip('checks random histories', () => fc.assert(property)) + `, + errors: [{ messageId: `skip` }], + }, + ], +}) diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js new file mode 100644 index 0000000000..d4bfcacabd --- /dev/null +++ b/scripts/eslint-plugin-oracle/rules/no-disabled.js @@ -0,0 +1,245 @@ +/** + * An oracle enforces its laws only in the tests that run. This rule reports + * the edits that stop a law from running while every test still passes: + * + * - `.only` on a test or suite, which silences every other law in the file. + * - `.skip` or `.todo` on a test or suite that has a body, inline or passed by + * name. An empty body is a placeholder that disables nothing, such as the + * test that a replay registers when it does not select its property. + * - `.skipIf(condition)` or `.runIf(condition)` unless the condition names a + * replay. Only a replay selection may switch an oracle test off. + * - Playwright's `test.skip()` or `test.skip(true, reason)` inside a test, + * which skips it unconditionally. `test.skip(condition, reason)` with an + * environment condition, such as the browser, is allowed. + * - A generated property limited to one unseeded run, by a literal + * `numRuns: 1` (or 0) or a base of 1 passed to the run helpers. One random + * history is not a generated property. A pinned replay passes a fixed + * `seed` beside its `numRuns`, or selects the one run under a condition + * that names the replay, so it is not reported. + * + * The rule has no inline exception. A known gap is pinned with `it.fails` on + * a fixed seed, not skipped. + * + * The rule cannot see a run count lowered to any value above 1, a condition + * that never selects a replay, or a skip reached through an alias such as + * `const run = it.skip`. Reviewers still check those. + */ + +const testRoots = new Set([`describe`, `fcTest`, `it`, `suite`, `test`]) +const conditionalModifiers = new Set([`runIf`, `skipIf`]) +const runHelpers = new Set([ + `oraclePropertyOptions`, + `oracleRandomParameters`, + `oracleRuns`, +]) + +/** The root identifier and member names of a callee such as `it.skip.prop(...)`. */ +function calleeChain(callee) { + const names = [] + let node = callee + for (;;) { + if (node.type === `MemberExpression`) { + if (node.property.type === `Identifier`) names.unshift(node.property.name) + node = node.object + } else if (node.type === `CallExpression`) { + node = node.callee + } else { + break + } + } + return { root: node.type === `Identifier` ? node.name : undefined, names } +} + +function isFunction(node) { + return ( + node?.type === `ArrowFunctionExpression` || + node?.type === `FunctionExpression` + ) +} + +/** A test body that runs something: a named function or a non-empty one. */ +function hasBody(body) { + if (!isFunction(body)) return true + return body.body.type !== `BlockStatement` || body.body.body.length > 0 +} + +function isStringLike(node) { + return ( + node.type === `TemplateLiteral` || + (node.type === `Literal` && typeof node.value === `string`) + ) +} + +function isBooleanLiteral(node) { + return node?.type === `Literal` && typeof node.value === `boolean` +} + +/** The condition of `it.skipIf(condition)` in a callee chain, if there is one. */ +function registrationCondition(callee) { + let node = callee + while (node.type === `CallExpression` || node.type === `MemberExpression`) { + if ( + node.type === `CallExpression` && + node.callee.type === `MemberExpression` && + conditionalModifiers.has(node.callee.property.name) + ) { + return { name: node.callee.property.name, condition: node.arguments[0] } + } + node = node.type === `CallExpression` ? node.callee : node.object + } + return undefined +} + +/** + * Whether a `.skip` or `.todo` call switches off a body that runs something. + * A test registration passes `(name, body)`. Playwright's runtime form passes + * `(condition, reason)`, `(condition)`, or nothing, and skips the test it + * runs in unless the condition is false. + */ +function disablesBody(args) { + if (args.length === 0) return true + const [first, second] = args + if (second === undefined || isStringLike(second)) { + return isBooleanLiteral(first) && first.value + } + return hasBody(second) +} + +/** A numeric literal at most 1: the run counts that check one history or none. */ +function isSingleRunLiteral(node) { + return ( + node?.type === `Literal` && + typeof node.value === `number` && + node.value <= 1 + ) +} + +/** + * Source text that names a replay: `replay` anywhere, or `path` or `seed` as + * a whole name, as in `options.path`. `filePath` names no replay. + */ +const replayText = /replay|(? propertyName(property) === `seed`, + ) + if (seed !== undefined) { + return !unfixedSeedText.test(sourceCode.getText(seed.value)) + } + const { parent } = object + if (parent.type === `ConditionalExpression` && parent.test !== object) { + return replayText.test(sourceCode.getText(parent.test)) + } + if (parent.type === `CallExpression` && parent.arguments.includes(object)) { + return calleeChain(parent.callee).root === `expect` + } + return false +} + +function propertyName(property) { + if (property.type !== `Property` || property.computed) return undefined + return property.key.type === `Identifier` + ? property.key.name + : property.key.value +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: `problem`, + docs: { + description: `Report oracle tests that stop running: .only, a skipped body, or one unseeded run.`, + }, + schema: [], + messages: { + only: `\`.only\` runs only this test, so every other law in the file stops running. Remove it before committing.`, + skip: `\`.{{name}}\` stops this test from enforcing its law. Remove it. To keep a known gap visible, pin a failing history with \`it.fails\` and a fixed seed.`, + conditional: `\`.{{name}}\` can switch this test off, and its condition names no replay. Only a replay selection may switch an oracle test off. Remove the condition.`, + singleRun: `A generated property with one unseeded run checks one random history. Pass a base run count to oracleRuns or oraclePropertyOptions; a pinned replay passes a fixed \`seed\` beside \`numRuns\`.`, + }, + }, + create(context) { + const { sourceCode } = context + + /** Whether `node` is the value of a `numRuns` option that a replay pins. */ + function isPinnedRunCount(node) { + const { parent } = node + return ( + parent.type === `Property` && + propertyName(parent) === `numRuns` && + isReplayOrObservation(parent.parent, sourceCode) + ) + } + + return { + CallExpression(node) { + // A curried call such as `fcTest.prop(arbs)(name, fn)` is reported + // once, at the outermost call, which holds the test body. + if ( + node.parent.type === `CallExpression` && + node.parent.callee === node + ) { + return + } + const { root, names } = calleeChain(node.callee) + if (runHelpers.has(root) && names.length === 0) { + if ( + isSingleRunLiteral(node.arguments[0]) && + !isPinnedRunCount(node) + ) { + context.report({ node: node.arguments[0], messageId: `singleRun` }) + } + return + } + if (!testRoots.has(root)) return + if (names.includes(`only`)) { + context.report({ + node: node.callee, + messageId: `only`, + }) + return + } + const conditional = registrationCondition(node.callee) + if (conditional !== undefined) { + if ( + conditional.condition === undefined || + !replayText.test(sourceCode.getText(conditional.condition)) + ) { + context.report({ + node, + messageId: `conditional`, + data: { name: conditional.name }, + }) + } + return + } + const disabled = names.find( + (name) => name === `skip` || name === `todo`, + ) + if (disabled !== undefined && disablesBody(node.arguments)) { + context.report({ node, messageId: `skip`, data: { name: disabled } }) + } + }, + Property(node) { + if ( + propertyName(node) === `numRuns` && + isSingleRunLiteral(node.value) && + !isPinnedRunCount(node.value) + ) { + context.report({ node, messageId: `singleRun` }) + } + }, + } + }, +} From 012f406d38b193c481930b83f426351dd11774a3 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:20:37 -0600 Subject: [PATCH 2/8] fix(oracles): pin one-run and replay exemptions structurally Replace the rule's "names a replay" heuristic, which wrong-branch conditionals, inverted conditions, the node:path module, and environment seeds all passed. A one-run options object now needs a `path` (a recorded counterexample) or a seed that is a number literal or a constant bound to one. `skipIf` accepts only `oracleReplayRequested()` from oracle-config, and `runIf` is always reported. The rule also reports Playwright `fixme`, context skips such as `ctx.skip()`, and runtime skips other than `browserName !== ''`. oraclePropertyOptions now returns one run for a path replay, so six campaign sites drop their own one-run conditional. The OPFS diagnostics oracle uses the shared replay variables, and the replay calibration fixture overrides its run count after the replay options are chosen. The enforcing pass lists tracked oracle files with git, so it skips untracked worktrees, and it fails when no main branch is available to compare against. CONTRIBUTING.md states that lint:changed runs only the oracle rules. Stale prose about the removed replay name is corrected. Refs #2234 Co-authored-by: Isaac --- CONTRIBUTING.md | 7 +- .../opfs-worker-diagnostics-oracle.test.ts | 26 ++- .../per-collection-coordinator-oracle.test.ts | 16 +- ...tructural-identity-oracle.property.test.ts | 10 +- packages/db/tests/oracle-config.ts | 15 ++ .../db/tests/oracle-replay.fixture.test.ts | 8 +- packages/db/tests/oracle-replay.test.ts | 19 +- ...ad-subset-replay-refinement-oracle.test.ts | 5 +- ...ordered-source-loader-state-oracle.test.ts | 5 +- ...rsor-pagination-oracle.integration.test.ts | 5 +- .../refetch-ordered-prefix-oracle.test.ts | 8 +- scripts/agent-lint-feedback.mjs | 70 ++++-- scripts/eslint-plugin-oracle/index.js | 2 +- .../eslint-plugin-oracle/no-disabled.test.js | 93 +++++++- .../eslint-plugin-oracle/rules/no-disabled.js | 212 ++++++++++++------ 15 files changed, 345 insertions(+), 156 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 985863341c..872abef797 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,9 +53,10 @@ If you have been assigned to fix an issue or develop a new feature, please follo pnpm lint ``` - `pnpm lint:changed` lints only the files that your branch changes. If you - work with a coding agent, the repository's hooks return oracle lint reports - to it as it edits. Codex and Gemini CLI run project hooks only after you + `pnpm lint:changed` runs only the `oracle/` rules, on the oracle files that + your branch changes. It does not replace `pnpm lint`. If you work with a + coding agent, the repository's hooks return oracle lint reports to it as it + edits. Codex and Gemini CLI run project hooks only after you trust them in `/hooks`; review them again when they change. - Implement your changes and tests in the relevant package or example. diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts index 67f813beb8..0a2bb16923 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts @@ -1,5 +1,10 @@ import { fc } from '@fast-check/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' +import { + oracleReplayRequested, + oracleRuns, + readOracleRunConfig, +} from '../../db/tests/oracle-config' import type { BrowserOPFSWorkerRequest, BrowserOPFSWorkerResponse, @@ -142,13 +147,13 @@ const diagnosticInput = fc.record({ ), }) -const replaySeed = process.env.TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_SEED -const diagnosticSeed = Number(replaySeed ?? 1846) -const diagnosticRuns = Number( - process.env.TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_RUNS ?? 40, -) -const diagnosticPath = process.env.TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_PATH -const replayRequested = replaySeed !== undefined || diagnosticPath !== undefined +const diagnosticProperty = `opfs-worker.diagnostics` +const replay = readOracleRunConfig() +const diagnosticSeed = replay.replaySeed ?? 1846 +const diagnosticRuns = oracleRuns(40) +const diagnosticPath = + replay.replayProperty === diagnosticProperty ? replay.replayPath : undefined +const replayRequested = oracleReplayRequested() const campaigns = replayRequested ? [{ name: `replay`, seed: diagnosticSeed }] : [ @@ -243,8 +248,9 @@ cause; the absent-cause examples reject an invented cause. Pinned examples run separately so both generated campaigns share one property and budget, and a reported shrink path selects its generated case directly. Verbose FastCheck output retains original/reduced traces. Set -TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_SEED and -TANSTACK_DB_OPFS_DIAGNOSTIC_ORACLE_PATH for direct replay. +TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, and +TANSTACK_DB_ORACLE_PROPERTY=opfs-worker.diagnostics for direct replay. +TANSTACK_DB_ORACLE_RUNS_MULTIPLIER scales the generated campaign. */ describe(`OPFS worker diagnostics oracle`, () => { if (!replayRequested) { @@ -254,7 +260,7 @@ describe(`OPFS worker diagnostics oracle`, () => { ) } - it.skipIf(replayRequested)( + it.skipIf(oracleReplayRequested())( `rejects a diagnostic that drops an exposed VFS cause`, async () => { const input = diagnosticExamples[4]![0] diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts index d94319f941..3b3b5f4272 100644 --- a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts @@ -5001,10 +5001,13 @@ packages/db/src/query/live/ARCHITECTURE.md) for a follower tab. A follower routes on-demand demand it cannot serve to the elected owner. Its load, and so its live query's first ready, settles only when the rows the owner fetched for it are visible in the follower; if applying the owner's commit fails here, the -load rejects and the follower's source enters error. The single-runtime lanes -`persistence.follower-remote-demand` and `persistence.follower-generation-notice` -in db-sqlite-persistence-core/tests/persisted-oracle.test.ts own the runtime -mechanism with a coordinator double. This lane is their receiving witness +load rejects and the follower's source enters error. The single-runtime +`persistence.follower-remote-demand` lane and the follower generation-notice +cells in db-sqlite-persistence-core/tests/persisted-oracle.test.ts own the +runtime mechanism with a coordinator double. The generation-notice cells are +fixed histories: their rotation cell is pinned as failing under the known gap +#2139, and the fix adds their generated campaign back. This lane is their +receiving witness across the real boundary: two tabs, each with its own real BrowserCollectionCoordinator over Node's real BroadcastChannel and Web Locks, and one real SQLite file opened by both tabs. @@ -5433,8 +5436,9 @@ describe(`follower remote-demand settlement across real tabs`, () => { // source. The model owes the same applied settlement there: the rows it // already showed stay visible, and a new demand settles with its rows. // This topology never routes to another tab's owner, so the - // elected-owner notice path stays covered by the single-runtime - // `persistence.follower-generation-notice` lane. + // elected-owner notice path stays with the single-runtime follower + // generation-notice cells in persisted-oracle.test.ts. Their rotation cell + // is pinned as failing under the known gap #2139. it( `keeps a rotated follower's rows and serves its new demand from its own generation`, () => diff --git a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts index 9b0e23d6f0..334c4a2508 100644 --- a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts @@ -462,10 +462,7 @@ describe(`structuralIdentity`, () => { ) if (options.seed === undefined) await fc.assert(property, { numRuns: options.numRuns, seed: 20261014 }) - await fc.assert( - property, - options.path === undefined ? options : { ...options, numRuns: 1 }, - ) + await fc.assert(property, options) }) // Named replays of counterexamples the random campaign found. Nested `deep` @@ -625,9 +622,6 @@ describe(`structuralIdentity`, () => { ) if (options.seed === undefined) await fc.assert(property, { numRuns: options.numRuns, seed: 1158727847 }) - await fc.assert( - property, - options.path === undefined ? options : { ...options, numRuns: 1 }, - ) + await fc.assert(property, options) }) }) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index dffda0f111..df7caae724 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -24,6 +24,7 @@ const staticOracleProperties = [ `oracle-replay.calibration`, `structural-identity.equal-representations`, `structural-identity.independent-values`, + `opfs-worker.diagnostics`, `sqlite-persistence.source-fifo-order`, `sqlite-persistence.abort-graph`, `sqlite-persistence.owner-isolation`, @@ -408,10 +409,14 @@ export function oracleRandomParameters( return { numRuns, seed: replaySeed, + // A path replays one recorded counterexample, so the replay runs once. + // From a path with one segment, fast-check would otherwise run `numRuns` + // histories that start at that index. ...(property !== undefined && replayPath !== undefined && replayProperty === property ? { + numRuns: 1, path: replayPath, ...oracleReplayReporter(property, replaySeed, replayPath), } @@ -421,6 +426,16 @@ export function oracleRandomParameters( const { multiplier, ...replay } = readOracleRunConfig() +/** + * Whether a replay campaign is selected. A test that is not the replayed + * property stands aside with `it.skipIf(oracleReplayRequested())`. The + * `oracle/no-disabled` lint rule accepts only this condition, so a replay + * selection cannot switch an oracle test off in ordinary runs. + */ +export function oracleReplayRequested(): boolean { + return replay.replaySeed !== undefined +} + /** Keeps ordinary CI bounded while allowing long randomized oracle campaigns. */ export function oracleRuns(baseRuns: number): number { return baseRuns * multiplier diff --git a/packages/db/tests/oracle-replay.fixture.test.ts b/packages/db/tests/oracle-replay.fixture.test.ts index 0d5ff458a5..23e3edb958 100644 --- a/packages/db/tests/oracle-replay.fixture.test.ts +++ b/packages/db/tests/oracle-replay.fixture.test.ts @@ -16,10 +16,10 @@ beforeEach(() => { if (fault === `setup`) throw new Error(`replay setup sentinel`) }) -const options = oraclePropertyOptions( - fault === `zero` ? 0 : 3, - `oracle-replay.calibration`, -) +// A path replay runs once, so the zero-run fault overrides the run count +// after the replay options are chosen, like the seed and path faults. +const options = oraclePropertyOptions(3, `oracle-replay.calibration`) +if (fault === `zero`) options.numRuns = 0 if (fault === `seed`) options.seed = 43 if (fault === `path`) options.path = `1` const propertyTest = diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index 539419facd..05da1ba869 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -201,7 +201,8 @@ describe(`guarded oracle replay`, () => { const result = runReplay(files) expect(result.status, result.output).toBe(0) expect(result.output).toContain(`"executions":1`) - expect(result.output).toContain(`"numRuns":3`) + // A path replay runs its recorded counterexample once. + expect(result.output).toContain(`"numRuns":1`) }, 40_000, ) @@ -300,6 +301,22 @@ describe(`oracle replay reporting`, () => { ) }) + it(`runs a path replay once, and only for its selected property`, () => { + vi.stubEnv(`TANSTACK_DB_ORACLE_REPLAY_WITNESS`, undefined) + const replay = { + replaySeed: 42, + replayPath: `3`, + replayProperty: `oracle-replay.calibration`, + } + expect( + oracleRandomParameters(5, replay, `oracle-replay.calibration`), + ).toEqual({ numRuns: 1, seed: 42, path: `3` }) + expect(oracleRandomParameters(5, replay, `cursor.exact-width`)).toEqual({ + numRuns: 5, + seed: 42, + }) + }) + it.each([false, true])( `preserves native async failure formatting and cause=%s`, async (errorWithCause) => { 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 32ec955ad6..f537b670d3 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 @@ -2133,10 +2133,7 @@ describe(`Ordered windows across truncate replays`, () => { ), campaign === `fixed` ? { seed: 20261021, numRuns: oracleRuns(300) } - : { - ...oraclePropertyOptions(300, orderedReplayProperty), - ...(campaign === `replay` ? { numRuns: 1 } : {}), - }, + : oraclePropertyOptions(300, orderedReplayProperty), ) // Reach witnesses: releases throw without a truncate, while a replay // starts, and while it settles; loads fail and stay held across a diff --git a/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts b/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts index 8deecfb889..78475bc857 100644 --- a/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts +++ b/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts @@ -3702,10 +3702,7 @@ describe(`Ordered source request authority`, () => { }), campaign === `fixed` ? { seed: 20261016, numRuns: oracleRuns(10000) } - : { - ...oraclePropertyOptions(10000, authorityProperty), - ...(campaign === `replay` ? { numRuns: 1 } : {}), - }, + : oraclePropertyOptions(10000, authorityProperty), ) // Reach witnesses: superseded requests fail, stale requests settle // after a repair succeeded, the strand law is checkable, a superseded diff --git a/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts b/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts index faa11fc5bb..7dc89ec31d 100644 --- a/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts @@ -351,10 +351,7 @@ describe(`cursor adapter through production pagination`, () => { ), campaign === `fixed` ? { seed: 20261012, numRuns: oracleRuns(50) } - : { - ...oraclePropertyOptions(50, manualWriteProperty), - ...(campaign === `replay` ? { numRuns: 1 } : {}), - }, + : oraclePropertyOptions(50, manualWriteProperty), ) } catch (error) { lawFailure = error diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts index f4d4ad4e90..2adf895b7f 100644 --- a/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts +++ b/packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts @@ -13,6 +13,7 @@ import fc from 'fast-check' import { describe, expect, it, vi } from 'vitest' import { oraclePropertyOptions, + oracleReplayRequested, oracleRuns, readOracleRunConfig, } from '../../db/tests/oracle-config.js' @@ -1967,7 +1968,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // The premise-directed campaign for the skip under a hold. Its reach // witness requires refetches that report while a hold is running. - it.runIf(replay.replayPath === undefined)( + it.skipIf(oracleReplayRequested())( `generated refetch histories under a hold (fixed campaign)`, async () => { let reachedReport = 0 @@ -1996,10 +1997,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => }), campaign === `fixed` ? { seed: 20261013, numRuns: oracleRuns(80) } - : { - ...oraclePropertyOptions(80, property), - ...(campaign === `replay` ? { numRuns: 1 } : {}), - }, + : oraclePropertyOptions(80, property), ) // Positive reach witness: generated histories serve continuation pages // before a refetch, which is the premise of the gap. diff --git a/scripts/agent-lint-feedback.mjs b/scripts/agent-lint-feedback.mjs index 635cfece92..fb4f22ff2f 100644 --- a/scripts/agent-lint-feedback.mjs +++ b/scripts/agent-lint-feedback.mjs @@ -34,12 +34,12 @@ */ import { execFileSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { isAbsolute, join, relative, resolve } from 'node:path' +import { basename, isAbsolute, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' import tsParser from '@typescript-eslint/parser' import { ESLint } from 'eslint' -import oraclePlugin, { oracleFiles } from './eslint-plugin-oracle/index.js' +import oraclePlugin from './eslint-plugin-oracle/index.js' const root = fileURLToPath(new URL(`../`, import.meta.url)) @@ -61,23 +61,49 @@ if (![`edit`, `stop`].includes(options.event)) { } const isHook = options.format !== `text` -/** Every file that the branch adds or changes, including uncommitted work. */ +function git(...args) { + return execFileSync(`git`, args, { cwd: root, encoding: `utf8` }).trim() +} + +function repositoryFiles(...outputs) { + return outputs + .flatMap((output) => output.split(`\n`)) + .filter(Boolean) + .map((file) => join(root, file)) +} + +/** + * Every tracked oracle file. Git leaves out builds and untracked checkouts, + * such as other branches' worktrees under `.worktrees/`. + */ +function trackedOracleFiles() { + return repositoryFiles(git(`ls-files`, `*oracle*.ts`, `*oracle*.tsx`)) +} + +/** + * Every file that the branch adds or changes, including uncommitted work. + * Without a main branch to compare against, the branch's own commits cannot + * be found, so this fails instead of linting only the uncommitted work. + */ function changedFiles() { - const git = (...args) => - execFileSync(`git`, args, { cwd: root, encoding: `utf8` }).trim() - let base = `HEAD` - try { - base = git(`merge-base`, `HEAD`, `origin/main`) - } catch { - // Without origin/main, the uncommitted work is the change. + const base = [`origin/main`, `main`] + .map((branch) => { + try { + return git(`merge-base`, `HEAD`, branch) + } catch { + return undefined + } + }) + .find((commit) => commit !== undefined) + if (base === undefined) { + throw new Error( + `Cannot find origin/main or main to compare this branch against. Run \`git fetch origin main\`.`, + ) } - return [ + return repositoryFiles( git(`diff`, `--name-only`, `--diff-filter=ACMR`, base), git(`ls-files`, `--others`, `--exclude-standard`), - ] - .flatMap((output) => output.split(`\n`)) - .filter(Boolean) - .map((file) => join(root, file)) + ) } const pathKeys = /^(file_?path|filePath|path|absolute_path|target_file)$/i @@ -136,19 +162,17 @@ function createLinter() { }) } -/** Lints the oracle files among `files`, or every oracle file without them. */ +/** Lints the oracle files among `files`. */ async function lint(eslint, files) { const lintable = [] - for (const file of new Set(files ?? [])) { + for (const file of new Set(files)) { if (!existsSync(file) || relative(root, file).startsWith(`..`)) continue - if (!/oracle[^/]*\.tsx?$/.test(file)) continue + if (!/oracle.*\.tsx?$/.test(basename(file))) continue if (await eslint.isPathIgnored(file)) continue lintable.push(file) } - if (files !== undefined && lintable.length === 0) return [] - const results = await eslint.lintFiles( - files === undefined ? oracleFiles : lintable, - ) + if (lintable.length === 0) return [] + const results = await eslint.lintFiles(lintable) // Keep oracle reports and files that cannot be parsed. This drops the // notices that the ignored directives have no effect. return results.map((result) => ({ @@ -228,7 +252,7 @@ function respond(report, input, scope) { /** The files to lint: named on the command line, edited, or changed. */ function selectFiles(input) { - if (options.all) return { scope: `all`, files: undefined } + if (options.all) return { scope: `all`, files: trackedOracleFiles() } if (positionals.length > 0) { return { scope: `named`, files: positionals.map((file) => resolve(file)) } } diff --git a/scripts/eslint-plugin-oracle/index.js b/scripts/eslint-plugin-oracle/index.js index 9588d8d324..1362fa2933 100644 --- a/scripts/eslint-plugin-oracle/index.js +++ b/scripts/eslint-plugin-oracle/index.js @@ -12,7 +12,7 @@ import noDisabled from './rules/no-disabled.js' * directives. */ -export const oracleFiles = [`**/*oracle*.ts`, `**/*oracle*.tsx`] +const oracleFiles = [`**/*oracle*.ts`, `**/*oracle*.tsx`] const plugin = { meta: { name: `eslint-plugin-oracle` }, diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js index 0e8ca8017b..291426cb1e 100644 --- a/scripts/eslint-plugin-oracle/no-disabled.test.js +++ b/scripts/eslint-plugin-oracle/no-disabled.test.js @@ -15,26 +15,26 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { // A replay that does not select this property registers an empty test. `it.skip('runs only when its replay property is selected', () => {})`, `it.todo('loads every subset from one generation')`, - // Playwright's runtime skip, conditioned on the environment. + // Playwright's runtime skip for a control that one browser supports. `test.skip(browserName !== 'chromium', 'Chromium-only control')`, - `test.skip(isMobile)`, - `test.skip(false, 'kept for a later browser')`, - // A replay selection may switch a test off. - `it.skipIf(replayRequested)('checks the history', () => check())`, - `it.runIf(replay.replayPath === undefined)('checks the history', () => check())`, + // A replay campaign stands the other tests aside. + `it.skipIf(oracleReplayRequested())('checks the history', () => check())`, `fc.assert(property, oraclePropertyOptions(12, 'cleanup-queue.history'))`, `fc.assert(property, { numRuns: oracleRuns(20) })`, - // A pinned replay or mutant witness runs one fixed history. + // A replay passes the recorded path, and a witness a fixed seed. + `fc.check(property, { seed: failed.seed, path: failed.counterexamplePath, numRuns: 1 })`, `fc.check(property, { seed: 205203, numRuns: 1 })`, - `fc.check(property, { seed: fixedSeed, numRuns: 1 })`, + `fc.check(property, { seed: -1659005, numRuns: 1 })`, + `const fixedSeed = 1657019\nfc.check(property, { seed: fixedSeed, numRuns: 1 })`, `fc.assert(property, { seed: 2128, numRuns: oracleRuns(1) })`, - `fc.assert(property, replayRequested ? { numRuns: oracleRuns(1) } : {})`, - `fc.assert(property, campaign === 'replay' ? { numRuns: 1 } : {})`, - `fc.assert(property, options.path === undefined ? options : { ...options, numRuns: 1 })`, - // An expectation about a recorded run is not a run option. + // An expectation about a recorded run is not a run option, and a sample + // runs no property. `expect(report).toMatchObject({ numRuns: 1, failed: true })`, + `fc.sample(historyArbitrary, { numRuns: 1 })`, // A known gap is pinned as failing on a fixed seed, not skipped. `it.fails('checks the fixed history', () => fc.assert(property, { seed: 2128, numRuns: oracleRuns(12) }))`, + // A skip on something other than the test context is not a test skip. + `it('reads a page', (ctx) => { const cursor = open(); cursor.skip() })`, ], invalid: [ { @@ -125,6 +125,75 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { code: `fc.assert(property, { numRuns: oracleRuns(0) })`, errors: [{ messageId: `singleRun` }], }, + { + code: `test.skip(isMobile)`, + errors: [{ messageId: `skip` }], + }, + { + code: `test.skip(process.env.CI === 'true', 'flaky')`, + errors: [{ messageId: `skip` }], + }, + { + code: `test.fixme('checks the history', check)`, + errors: [{ messageId: `skip`, data: { name: `fixme` } }], + }, + { + code: `test.describe.fixme('cleanup queue', () => { test('a', check) })`, + errors: [{ messageId: `skip`, data: { name: `fixme` } }], + }, + { + code: `test.fixme()`, + errors: [{ messageId: `skip`, data: { name: `fixme` } }], + }, + { + code: `it('checks the history', (ctx) => { ctx.skip(); check() })`, + errors: [{ messageId: `skip` }], + }, + { + // Only the shared helper may select a replay: its polarity is fixed. + code: `it.skipIf(replayRequested)('checks the history', () => check())`, + errors: [{ messageId: `conditional` }], + }, + { + code: `it.runIf(replay.replayPath === undefined)('checks the history', () => check())`, + errors: [{ messageId: `conditional`, data: { name: `runIf` } }], + }, + { + code: `it.runIf(replay.replayPath !== undefined)('checks the history', check)`, + errors: [{ messageId: `conditional` }], + }, + { + code: `describe.skipIf(!oracleReplayRequested())('cleanup queue', () => { it('a', () => check()) })`, + errors: [{ messageId: `conditional` }], + }, + { + code: `describe.skipIf(path.sep === '\\\\')('cleanup queue', () => { it('a', () => check()) })`, + errors: [{ messageId: `conditional` }], + }, + { + code: `fc.assert(property, replayRequested ? {} : { numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, campaign === 'replay' ? { numRuns: 1 } : {})`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, { seed: process.env.SEED, numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, { seed: replay.replaySeed, numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `let seed = 1\nfc.assert(property, { seed, numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, { seed: replay.replaySeed, path: undefined, numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, { // A comment does not grant an exception. code: ` diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js index d4bfcacabd..8644bbd65f 100644 --- a/scripts/eslint-plugin-oracle/rules/no-disabled.js +++ b/scripts/eslint-plugin-oracle/rules/no-disabled.js @@ -3,30 +3,38 @@ * the edits that stop a law from running while every test still passes: * * - `.only` on a test or suite, which silences every other law in the file. - * - `.skip` or `.todo` on a test or suite that has a body, inline or passed by - * name. An empty body is a placeholder that disables nothing, such as the - * test that a replay registers when it does not select its property. - * - `.skipIf(condition)` or `.runIf(condition)` unless the condition names a - * replay. Only a replay selection may switch an oracle test off. - * - Playwright's `test.skip()` or `test.skip(true, reason)` inside a test, - * which skips it unconditionally. `test.skip(condition, reason)` with an - * environment condition, such as the browser, is allowed. - * - A generated property limited to one unseeded run, by a literal - * `numRuns: 1` (or 0) or a base of 1 passed to the run helpers. One random - * history is not a generated property. A pinned replay passes a fixed - * `seed` beside its `numRuns`, or selects the one run under a condition - * that names the replay, so it is not reported. + * - `.skip`, `.todo`, or Playwright's `.fixme` on a test or suite that has a + * body, inline or passed by name. An empty body is a placeholder that + * disables nothing, such as the test that a replay registers when it does + * not select its property. + * - A test that skips itself through its context, as in `ctx.skip()`. + * - `.skipIf(condition)` or `.runIf(condition)`, unless the call is exactly + * `.skipIf(oracleReplayRequested())`. A replay may stand a test aside, and + * nothing else may switch an oracle test off. The shared helper fixes the + * polarity, so a condition cannot turn the test off in ordinary runs. + * - Playwright's runtime `test.skip()` or `test.fixme()` inside a test, + * unless its condition is `browserName !== ''`: a control that + * only one browser supports. + * - A generated property limited to one run that pins no history: a literal + * `numRuns` of 1 or 0, or a base of 1 passed to the run helpers. One random + * history is not a generated property. A replay pins its history with a + * `path` beside `numRuns`, and a witness with a seed that is a number + * literal or a constant bound to one. `oraclePropertyOptions` returns one + * run for a path replay, so a campaign needs no one-run option of its own. * * The rule has no inline exception. A known gap is pinned with `it.fails` on * a fixed seed, not skipped. * - * The rule cannot see a run count lowered to any value above 1, a condition - * that never selects a replay, or a skip reached through an alias such as - * `const run = it.skip`. Reviewers still check those. + * The rule cannot see a run count lowered to any value above 1, a `path` or + * seed read from configuration that is absent in ordinary runs, a skip + * reached through an alias such as `const run = it.skip`, or a registration + * inside an `if`. Reviewers still check those. */ const testRoots = new Set([`describe`, `fcTest`, `it`, `suite`, `test`]) +const disablingModifiers = new Set([`fixme`, `skip`, `todo`]) const conditionalModifiers = new Set([`runIf`, `skipIf`]) +const replaySelection = `oracleReplayRequested` const runHelpers = new Set([ `oraclePropertyOptions`, `oracleRandomParameters`, @@ -70,11 +78,7 @@ function isStringLike(node) { ) } -function isBooleanLiteral(node) { - return node?.type === `Literal` && typeof node.value === `boolean` -} - -/** The condition of `it.skipIf(condition)` in a callee chain, if there is one. */ +/** The `skipIf` or `runIf` call in a callee chain, if there is one. */ function registrationCondition(callee) { let node = callee while (node.type === `CallExpression` || node.type === `MemberExpression`) { @@ -83,24 +87,49 @@ function registrationCondition(callee) { node.callee.type === `MemberExpression` && conditionalModifiers.has(node.callee.property.name) ) { - return { name: node.callee.property.name, condition: node.arguments[0] } + return { name: node.callee.property.name, args: node.arguments } } node = node.type === `CallExpression` ? node.callee : node.object } return undefined } +/** Exactly `.skipIf(oracleReplayRequested())`. */ +function isReplaySelection({ name, args }) { + const [condition] = args + return ( + name === `skipIf` && + args.length === 1 && + condition.type === `CallExpression` && + condition.callee.type === `Identifier` && + condition.callee.name === replaySelection && + condition.arguments.length === 0 + ) +} + +/** Playwright's `browserName !== ''`. */ +function isBrowserCondition(node) { + return ( + node.type === `BinaryExpression` && + node.operator === `!==` && + node.left.type === `Identifier` && + node.left.name === `browserName` && + isStringLike(node.right) + ) +} + /** - * Whether a `.skip` or `.todo` call switches off a body that runs something. - * A test registration passes `(name, body)`. Playwright's runtime form passes - * `(condition, reason)`, `(condition)`, or nothing, and skips the test it - * runs in unless the condition is false. + * Whether a `.skip`, `.todo`, or `.fixme` call switches off a body that runs + * something. A test registration passes `(name, body)`, and a todo passes + * only its name. Playwright's runtime form passes `(condition, reason)`, + * `(condition)`, or nothing, and skips the test that it runs in. */ function disablesBody(args) { - if (args.length === 0) return true const [first, second] = args + if (first === undefined) return true + if (second === undefined && isStringLike(first)) return false if (second === undefined || isStringLike(second)) { - return isBooleanLiteral(first) && first.value + return !isBrowserCondition(first) } return hasBody(second) } @@ -114,37 +143,31 @@ function isSingleRunLiteral(node) { ) } -/** - * Source text that names a replay: `replay` anywhere, or `path` or `seed` as - * a whole name, as in `options.path`. `filePath` names no replay. - */ -const replayText = /replay|(? propertyName(property) === `seed`, +/** A number literal, or a constant bound to one. */ +function isFixedSeed(node, sourceCode) { + if (isNumberLiteral(node)) return true + if (node.type !== `Identifier`) return false + const definition = findVariable(sourceCode.getScope(node), node.name)?.defs[0] + return ( + definition?.type === `Variable` && + definition.parent.kind === `const` && + isNumberLiteral(definition.node.init) ) - if (seed !== undefined) { - return !unfixedSeedText.test(sourceCode.getText(seed.value)) - } - const { parent } = object - if (parent.type === `ConditionalExpression` && parent.test !== object) { - return replayText.test(sourceCode.getText(parent.test)) - } - if (parent.type === `CallExpression` && parent.arguments.includes(object)) { - return calleeChain(parent.callee).root === `expect` - } - return false } function propertyName(property) { @@ -154,19 +177,69 @@ function propertyName(property) { : property.key.value } +/** + * A one-run object pins its history when it replays a `path` or passes a + * fixed seed. An object that an `expect` matcher compares holds an observed + * run count, and `fc.sample` draws examples without running a property. + */ +function isPinnedOrObservation(object, sourceCode) { + const option = (name) => + object.properties.find((property) => propertyName(property) === name) + const path = option(`path`) + if (path !== undefined && sourceCode.getText(path.value) !== `undefined`) { + return true + } + const seed = option(`seed`) + if (seed !== undefined && isFixedSeed(seed.value, sourceCode)) return true + const { parent } = object + if (parent.type !== `CallExpression` || !parent.arguments.includes(object)) { + return false + } + const { root, names } = calleeChain(parent.callee) + return root === `expect` || (root === `fc` && names[0] === `sample`) +} + +/** + * Whether a call skips the test that it runs in through the test context, + * as in `it('law', (ctx) => { ctx.skip() })`. + */ +function isContextSkip(node) { + const { callee } = node + if ( + callee.type !== `MemberExpression` || + callee.property.type !== `Identifier` || + callee.property.name !== `skip` || + callee.object.type !== `Identifier` + ) { + return false + } + let fn = node.parent + while (fn && !isFunction(fn)) fn = fn.parent + const [context] = fn?.params ?? [] + if (context?.type !== `Identifier` || context.name !== callee.object.name) { + return false + } + const registration = fn.parent + return ( + registration.type === `CallExpression` && + registration.arguments.includes(fn) && + testRoots.has(calleeChain(registration.callee).root) + ) +} + /** @type {import('eslint').Rule.RuleModule} */ export default { meta: { type: `problem`, docs: { - description: `Report oracle tests that stop running: .only, a skipped body, or one unseeded run.`, + description: `Report oracle tests that stop running: .only, a skipped body, a switch-off condition, or one unpinned run.`, }, schema: [], messages: { only: `\`.only\` runs only this test, so every other law in the file stops running. Remove it before committing.`, skip: `\`.{{name}}\` stops this test from enforcing its law. Remove it. To keep a known gap visible, pin a failing history with \`it.fails\` and a fixed seed.`, - conditional: `\`.{{name}}\` can switch this test off, and its condition names no replay. Only a replay selection may switch an oracle test off. Remove the condition.`, - singleRun: `A generated property with one unseeded run checks one random history. Pass a base run count to oracleRuns or oraclePropertyOptions; a pinned replay passes a fixed \`seed\` beside \`numRuns\`.`, + conditional: `\`.{{name}}\` can switch this test off in ordinary runs. Only \`.skipIf(oracleReplayRequested())\` may switch an oracle test off, so that a replay can stand it aside. Remove the condition.`, + singleRun: `A generated property with one run that pins no history checks one random history. Pass a base run count to oracleRuns or oraclePropertyOptions. A replay passes \`path\`, and a witness passes a seed that is a number literal or a constant.`, }, }, create(context) { @@ -178,7 +251,8 @@ export default { return ( parent.type === `Property` && propertyName(parent) === `numRuns` && - isReplayOrObservation(parent.parent, sourceCode) + parent.parent.type === `ObjectExpression` && + isPinnedOrObservation(parent.parent, sourceCode) ) } @@ -192,6 +266,10 @@ export default { ) { return } + if (isContextSkip(node)) { + context.report({ node, messageId: `skip`, data: { name: `skip` } }) + return + } const { root, names } = calleeChain(node.callee) if (runHelpers.has(root) && names.length === 0) { if ( @@ -204,18 +282,12 @@ export default { } if (!testRoots.has(root)) return if (names.includes(`only`)) { - context.report({ - node: node.callee, - messageId: `only`, - }) + context.report({ node: node.callee, messageId: `only` }) return } const conditional = registrationCondition(node.callee) if (conditional !== undefined) { - if ( - conditional.condition === undefined || - !replayText.test(sourceCode.getText(conditional.condition)) - ) { + if (!isReplaySelection(conditional)) { context.report({ node, messageId: `conditional`, @@ -224,9 +296,7 @@ export default { } return } - const disabled = names.find( - (name) => name === `skip` || name === `todo`, - ) + const disabled = names.find((name) => disablingModifiers.has(name)) if (disabled !== undefined && disablesBody(node.arguments)) { context.report({ node, messageId: `skip`, data: { name: disabled } }) } From 2cf76d5cf066a0bd0c750aee8f6ea0fa2cbc3ae1 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:28:00 -0600 Subject: [PATCH 3/8] test(oracles): expect one run from a path replay in the run-config test The previous commit made oraclePropertyOptions run a path replay once. utils.test.ts still expected the base run count for the named property. Refs #2234 Co-authored-by: Isaac --- packages/db/tests/utils.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/utils.test.ts b/packages/db/tests/utils.test.ts index 1bbf4e4730..eff8abb949 100644 --- a/packages/db/tests/utils.test.ts +++ b/packages/db/tests/utils.test.ts @@ -169,9 +169,10 @@ describe(`oracle run configuration`, () => { numRuns: 40, seed: -42, }) + // A path replays one recorded counterexample, so it runs once. expect( oracleRandomParameters(40, replayRun, `includes.incremental-history`), - ).toEqual({ numRuns: 40, seed: -42, path: `1:0:2` }) + ).toEqual({ numRuns: 1, seed: -42, path: `1:0:2` }) }) }) From 73104aea045a42ed29f89a69914e9473c65aad4f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 11:35:08 -0600 Subject: [PATCH 4/8] fix(oracles): resolve replay helper and test context through scope Address CodeRabbit's review of 012f406d3: - oracleReplayRequested() means a path replay. A seed without a path only reseeds the random campaigns, so fixed tests keep running. - The OPFS diagnostics oracle takes its replay and random options from oraclePropertyOptions, so a path replay runs once and a seed-only run reseeds only the random campaign. - oracle/no-disabled accepts skipIf(oracleReplayRequested()) only when the helper is imported from oracle-config, so a local function of that name does not pass. - The context-skip check resolves the binding through scope, so a context captured by a nested callback is reported and a shadowing variable is not. Refs #2234 Co-authored-by: Isaac --- .../opfs-worker-diagnostics-oracle.test.ts | 43 +++++++++++------- packages/db/tests/oracle-config.ts | 12 ++--- .../eslint-plugin-oracle/no-disabled.test.js | 25 ++++++++++- .../eslint-plugin-oracle/rules/no-disabled.js | 44 ++++++++++++------- 4 files changed, 86 insertions(+), 38 deletions(-) diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts index 0a2bb16923..32b3844169 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts @@ -1,6 +1,7 @@ import { fc } from '@fast-check/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' import { + oraclePropertyOptions, oracleReplayRequested, oracleRuns, readOracleRunConfig, @@ -148,18 +149,30 @@ const diagnosticInput = fc.record({ }) const diagnosticProperty = `opfs-worker.diagnostics` +const diagnosticRuns = 40 +// A path replay runs only the selected property's recorded history, once. A +// seed without a path reseeds the random campaign and keeps the fixed one. const replay = readOracleRunConfig() -const diagnosticSeed = replay.replaySeed ?? 1846 -const diagnosticRuns = oracleRuns(40) -const diagnosticPath = - replay.replayProperty === diagnosticProperty ? replay.replayPath : undefined -const replayRequested = oracleReplayRequested() -const campaigns = replayRequested - ? [{ name: `replay`, seed: diagnosticSeed }] - : [ - { name: `fixed`, seed: diagnosticSeed }, - { name: `random`, seed: undefined }, - ] +const campaigns = + replay.replayPath === undefined + ? [ + { + name: `fixed`, + options: { seed: 1846, numRuns: oracleRuns(diagnosticRuns) }, + }, + { + name: `random`, + options: oraclePropertyOptions(diagnosticRuns, diagnosticProperty), + }, + ] + : replay.replayProperty === diagnosticProperty + ? [ + { + name: `replay`, + options: oraclePropertyOptions(diagnosticRuns, diagnosticProperty), + }, + ] + : [] const diagnosticExamples: Array<[DiagnosticInput]> = [ [ @@ -253,7 +266,7 @@ TANSTACK_DB_ORACLE_PROPERTY=opfs-worker.diagnostics for direct replay. TANSTACK_DB_ORACLE_RUNS_MULTIPLIER scales the generated campaign. */ describe(`OPFS worker diagnostics oracle`, () => { - if (!replayRequested) { + if (!oracleReplayRequested()) { it.each(diagnosticExamples)( `preserves the pinned open-failure diagnostic (%#)`, checkDiagnostic, @@ -280,11 +293,9 @@ describe(`OPFS worker diagnostics oracle`, () => { it.each(campaigns)( `matches the independent diagnostic formatter ($name campaign)`, - async ({ seed }) => { + async ({ options }) => { await fc.assert(fc.asyncProperty(diagnosticInput, checkDiagnostic), { - ...(seed === undefined ? {} : { seed }), - numRuns: diagnosticRuns, - ...(diagnosticPath ? { path: diagnosticPath } : {}), + ...options, verbose: true, }) }, diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index df7caae724..46e6efb6b2 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -427,13 +427,15 @@ export function oracleRandomParameters( const { multiplier, ...replay } = readOracleRunConfig() /** - * Whether a replay campaign is selected. A test that is not the replayed - * property stands aside with `it.skipIf(oracleReplayRequested())`. The - * `oracle/no-disabled` lint rule accepts only this condition, so a replay - * selection cannot switch an oracle test off in ordinary runs. + * Whether a path replay is selected. It replays one property's recorded + * history, so a test that is not that property stands aside with + * `it.skipIf(oracleReplayRequested())`. A seed without a path only reseeds + * the random campaigns, so every test still runs. The `oracle/no-disabled` + * lint rule accepts only this condition, so a replay selection cannot switch + * an oracle test off in ordinary runs. */ export function oracleReplayRequested(): boolean { - return replay.replaySeed !== undefined + return replay.replayPath !== undefined } /** Keeps ordinary CI bounded while allowing long randomized oracle campaigns. */ diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js index 291426cb1e..c241383bd3 100644 --- a/scripts/eslint-plugin-oracle/no-disabled.test.js +++ b/scripts/eslint-plugin-oracle/no-disabled.test.js @@ -18,7 +18,14 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { // Playwright's runtime skip for a control that one browser supports. `test.skip(browserName !== 'chromium', 'Chromium-only control')`, // A replay campaign stands the other tests aside. - `it.skipIf(oracleReplayRequested())('checks the history', () => check())`, + { + code: `import { oracleReplayRequested } from '../../db/tests/oracle-config.js'\nit.skipIf(oracleReplayRequested())('checks the history', () => check())`, + languageOptions: { sourceType: `module` }, + }, + { + code: `import { oracleReplayRequested } from './oracle-config'\nit.skipIf(oracleReplayRequested())('checks the history', () => check())`, + languageOptions: { sourceType: `module` }, + }, `fc.assert(property, oraclePropertyOptions(12, 'cleanup-queue.history'))`, `fc.assert(property, { numRuns: oracleRuns(20) })`, // A replay passes the recorded path, and a witness a fixed seed. @@ -35,6 +42,9 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { `it.fails('checks the fixed history', () => fc.assert(property, { seed: 2128, numRuns: oracleRuns(12) }))`, // A skip on something other than the test context is not a test skip. `it('reads a page', (ctx) => { const cursor = open(); cursor.skip() })`, + // An inner variable that shadows the context is not the test context. + `it('reads a page', (ctx) => { pages.forEach((ctx) => ctx.skip()) })`, + `it('reads a page', (_, ctx) => { ctx.skip() })`, ], invalid: [ { @@ -149,6 +159,19 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { code: `it('checks the history', (ctx) => { ctx.skip(); check() })`, errors: [{ messageId: `skip` }], }, + { + code: `it('checks the history', (ctx) => { steps.forEach(() => ctx.skip()) })`, + errors: [{ messageId: `skip` }], + }, + { + // A local function with the helper's name is not the shared helper. + code: `const oracleReplayRequested = () => process.env.CI === 'true'\nit.skipIf(oracleReplayRequested())('checks the history', () => check())`, + errors: [{ messageId: `conditional` }], + }, + { + code: `it.skipIf(oracleReplayRequested())('checks the history', () => check())`, + errors: [{ messageId: `conditional` }], + }, { // Only the shared helper may select a replay: its polarity is fixed. code: `it.skipIf(replayRequested)('checks the history', () => check())`, diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js index 8644bbd65f..19c0f6313a 100644 --- a/scripts/eslint-plugin-oracle/rules/no-disabled.js +++ b/scripts/eslint-plugin-oracle/rules/no-disabled.js @@ -95,15 +95,26 @@ function registrationCondition(callee) { } /** Exactly `.skipIf(oracleReplayRequested())`. */ -function isReplaySelection({ name, args }) { +function isReplaySelection({ name, args }, sourceCode) { const [condition] = args + if ( + name !== `skipIf` || + args.length !== 1 || + condition.type !== `CallExpression` || + condition.callee.type !== `Identifier` || + condition.callee.name !== replaySelection || + condition.arguments.length > 0 + ) { + return false + } + // The helper must be the shared one, not a local function of that name. + const definition = findVariable( + sourceCode.getScope(condition), + replaySelection, + )?.defs[0] return ( - name === `skipIf` && - args.length === 1 && - condition.type === `CallExpression` && - condition.callee.type === `Identifier` && - condition.callee.name === replaySelection && - condition.arguments.length === 0 + definition?.type === `ImportBinding` && + /(^|\/)oracle-config(\.js)?$/.test(definition.parent.source.value) ) } @@ -203,7 +214,7 @@ function isPinnedOrObservation(object, sourceCode) { * Whether a call skips the test that it runs in through the test context, * as in `it('law', (ctx) => { ctx.skip() })`. */ -function isContextSkip(node) { +function isContextSkip(node, sourceCode) { const { callee } = node if ( callee.type !== `MemberExpression` || @@ -213,12 +224,13 @@ function isContextSkip(node) { ) { return false } - let fn = node.parent - while (fn && !isFunction(fn)) fn = fn.parent - const [context] = fn?.params ?? [] - if (context?.type !== `Identifier` || context.name !== callee.object.name) { - return false - } + // Resolve the binding, so a context captured by a nested callback counts + // and an unrelated variable of the same name does not. + const definition = findVariable(sourceCode.getScope(node), callee.object.name) + ?.defs[0] + if (definition?.type !== `Parameter`) return false + const fn = definition.node + if (fn.params[0] !== definition.name) return false const registration = fn.parent return ( registration.type === `CallExpression` && @@ -266,7 +278,7 @@ export default { ) { return } - if (isContextSkip(node)) { + if (isContextSkip(node, sourceCode)) { context.report({ node, messageId: `skip`, data: { name: `skip` } }) return } @@ -287,7 +299,7 @@ export default { } const conditional = registrationCondition(node.callee) if (conditional !== undefined) { - if (!isReplaySelection(conditional)) { + if (!isReplaySelection(conditional, sourceCode)) { context.report({ node, messageId: `conditional`, From aa4a496744d0addf8178d000fd2c73e79f271e2f Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 13:17:45 -0600 Subject: [PATCH 5/8] feat(oracles): report aliased, guarded, and unscaled oracle tests oracle/no-disabled now requires every replay selection to go through the shared oracle-config helpers, so a selection cannot switch an oracle test off in an ordinary run: - A test modifier used as a value (`replaying ? it.skip : it`) is reported. oracleReplayTest(registration, ...properties) selects a replay instead. - A test registered under a condition, or after an early return, is reported unless the condition is oracleReplayRequested() or oracleReplaySkips(...properties), or a negation of one. An early throw fails loudly, so it guards nothing. Calls such as Playwright's test.info() are not registrations. - skipIf accepts oracleReplaySkips(...properties) as well. - A one-run path must be recorded: a literal, or a failure's counterexamplePath. A replay from configuration uses oraclePropertyOptions. - A literal run count above 1 that pins no history is reported, because it does not scale with TANSTACK_DB_ORACLE_RUNS_MULTIPLIER. oracle-config gains oracleReplaySkips and oracleReplayTest, both of which accept several properties. The registry adds the names that the migrated oracles select: numbered utils and comparison families, IndexedDB cross-tab history, OPFS diagnostics, and the package oracles that had their own replay variables. Refs #2234 Co-authored-by: Isaac --- packages/db/tests/oracle-config.ts | 107 +++++++- .../eslint-plugin-oracle/no-disabled.test.js | 57 +++++ .../eslint-plugin-oracle/rules/no-disabled.js | 229 +++++++++++++++--- 3 files changed, 350 insertions(+), 43 deletions(-) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 46e6efb6b2..e370f1f4c7 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -25,6 +25,52 @@ const staticOracleProperties = [ `structural-identity.equal-representations`, `structural-identity.independent-values`, `opfs-worker.diagnostics`, + `indexeddb.cross-tab-history`, + `electric.adapter-failure-policy`, + `electric.design-convergence`, + `electric.durable-prefix`, + `electric.initial-tagged-move-outs`, + `electric.invalid-resume-partition`, + `electric.metadata-merge-complete-state`, + `electric.process-grammar`, + `electric.publication-before-persistence`, + `electric.queued-presence-order`, + `electric.resume-metadata-merge`, + `electric.retry-decision`, + `electric.subset-request-row-validity`, + `electric.synthetic-batch-partition`, + `electric.valid-batch-partition`, + `ivm.index-refinement`, + `offline.key-scheduler-fifo-ledger`, + `op-sqlite.columnar-sql-aliases`, + `op-sqlite.result-carrier-equivalence`, + `op-sqlite.write-marker-aliases`, + `pg-serializer.array-determinism`, + `pg-serializer.bigint-round-trip`, + `pg-serializer.bigint-text`, + `pg-serializer.boolean-array-contents`, + `pg-serializer.boolean-round-trip`, + `pg-serializer.boolean-tokens`, + `pg-serializer.date-iso`, + `pg-serializer.date-round-trip`, + `pg-serializer.double-round-trip`, + `pg-serializer.integer-array-contents`, + `pg-serializer.integer-array-format`, + `pg-serializer.integer-round-trip`, + `pg-serializer.integer-text`, + `pg-serializer.mixed-array-contents`, + `pg-serializer.nullish-array-contents`, + `pg-serializer.nullish-empty`, + `pg-serializer.scalar-determinism`, + `pg-serializer.string-array-contents`, + `pg-serializer.string-escaping`, + `pg-serializer.string-idempotent`, + `pg-serializer.string-passthrough`, + `sqlite-persistence.bigint-rejection`, + `sqlite-persistence.bigint-roundtrip`, + `sqlite-persistence.binding-capacity`, + `sqlite-persistence.expression-index`, + `sqlite-resume.baseline-lineage`, `sqlite-persistence.source-fifo-order`, `sqlite-persistence.abort-graph`, `sqlite-persistence.owner-isolation`, @@ -216,6 +262,15 @@ const refinementProperties = Array.from( { length: 11 }, (_, index) => `load-subset-refinement.${1_779_001 + index}`, ) +// utils-oracle and comparison-oracle number their properties in file order. +const utilsProperties = Array.from( + { length: 40 }, + (_, index) => `utils.${index + 1}`, +) +const comparisonProperties = Array.from( + { length: 39 }, + (_, index) => `comparison.${index + 1}`, +) const paginationMatrixProperties = [`window`, `state`].flatMap((family) => Array.from( { length: 8 }, @@ -302,6 +357,8 @@ export const registeredOracleProperties = validateOraclePropertyRegistry([ ...staticOracleProperties, ...publicationProperties, ...refinementProperties, + ...utilsProperties, + ...comparisonProperties, ...paginationMatrixProperties, ...includesMatrixProperties, ]) @@ -427,17 +484,55 @@ export function oracleRandomParameters( const { multiplier, ...replay } = readOracleRunConfig() /** - * Whether a path replay is selected. It replays one property's recorded - * history, so a test that is not that property stands aside with - * `it.skipIf(oracleReplayRequested())`. A seed without a path only reseeds - * the random campaigns, so every test still runs. The `oracle/no-disabled` - * lint rule accepts only this condition, so a replay selection cannot switch - * an oracle test off in ordinary runs. + * Replay selection. + * + * A path replay runs one property's recorded history. Every other test + * stands aside, so the replay reports only that history. A seed without a + * path only reseeds the random campaigns, so every test still runs. These + * helpers are the only replay conditions that the `oracle/no-disabled` lint + * rule accepts. Each is false in an ordinary run, so no replay selection can + * switch an oracle test off there. */ + +/** Whether a path replay is selected. */ export function oracleReplayRequested(): boolean { return replay.replayPath !== undefined } +/** + * Whether a path replay of a property outside `properties` is selected. A + * test that serves several properties' replays passes all of them. + */ +export function oracleReplaySkips( + ...properties: ReadonlyArray +): boolean { + for (const property of properties) assertRegisteredOracleProperty(property) + return ( + replay.replayPath !== undefined && + (replay.replayProperty === undefined || + !properties.includes(replay.replayProperty)) + ) +} + +/** + * The registration to use for a test that stands aside during another + * test's replay: its `skip` form while that replay runs, and itself + * otherwise. Without `properties`, the test stands aside during every path + * replay. With them, it runs only when no path replay is selected or the + * replay selects one of them. + */ +export function oracleReplayTest( + registration: TRegistration, + ...properties: ReadonlyArray +): TRegistration { + const standsAside = + properties.length === 0 + ? oracleReplayRequested() + : oracleReplaySkips(...properties) + // `skip` registers through the same API, so it has the registration's type. + return standsAside ? (registration.skip as TRegistration) : registration +} + /** Keeps ordinary CI bounded while allowing long randomized oracle campaigns. */ export function oracleRuns(baseRuns: number): number { return baseRuns * multiplier diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js index c241383bd3..1850432700 100644 --- a/scripts/eslint-plugin-oracle/no-disabled.test.js +++ b/scripts/eslint-plugin-oracle/no-disabled.test.js @@ -40,6 +40,23 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { `fc.sample(historyArbitrary, { numRuns: 1 })`, // A known gap is pinned as failing on a fixed seed, not skipped. `it.fails('checks the fixed history', () => fc.assert(property, { seed: 2128, numRuns: oracleRuns(12) }))`, + // The shared replay helpers may select a replay, in either polarity for + // a registration and through the alias helper. + ...[ + `it.skipIf(oracleReplaySkips('cursor.exact-width'))('a', () => check())`, + `if (!oracleReplayRequested()) { it('a', () => check()) }`, + `if (oracleReplayRequested()) { it('replays the path', () => check()) }`, + `const propertyTest = oracleReplayTest(fcTest, 'cursor.exact-width')\npropertyTest.prop([a])('a', check)`, + `describe('s', () => { if (oracleReplaySkips('cursor.exact-width')) return\n it('a', () => check()) })`, + ].map((code) => ({ + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\n${code}`, + languageOptions: { sourceType: `module` }, + })), + // Playwright's test API calls that register nothing. + `test('a', async () => { if (rejected) { expect(test.info().project.name).not.toBe('webkit') } })`, + // Registrations in a loop or over a table run in every ordinary run. + `for (const campaign of campaigns) { it(campaign.name, () => check(campaign)) }`, + `const fixedRuns = { seed: 1846, numRuns: 40 }`, // A skip on something other than the test context is not a test skip. `it('reads a page', (ctx) => { const cursor = open(); cursor.skip() })`, // An inner variable that shadows the context is not the test context. @@ -217,6 +234,46 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { code: `fc.assert(property, { seed: replay.replaySeed, path: undefined, numRuns: 1 })`, errors: [{ messageId: `singleRun` }], }, + { + code: `const propertyTest = replay.replayPath === undefined ? fcTest : fcTest.skip`, + errors: [{ messageId: `alias`, data: { name: `skip` } }], + }, + { + code: `const run = it.skip\nrun('a', () => check())`, + errors: [{ messageId: `alias` }], + }, + { + code: `const run = fcTest.skip.prop([a])\nrun('a', check)`, + errors: [{ messageId: `skip` }], + }, + { + code: `if (!replayRequested) { it('a', () => check()) }`, + errors: [{ messageId: `guarded` }], + }, + { + code: `describe('s', () => { if (requested !== id) { return }\n it('a', () => check()) })`, + errors: [{ messageId: `guarded` }], + }, + { + code: `process.env.CI || it('a', () => check())`, + errors: [{ messageId: `guarded` }], + }, + { + code: `const oracleReplayRequested = () => true\nif (!oracleReplayRequested()) { it('a', () => check()) }`, + errors: [{ messageId: `guarded` }], + }, + { + code: `fc.assert(property, { seed: replay.replaySeed, path: replay.replayPath, numRuns: 1 })`, + errors: [{ messageId: `singleRun` }], + }, + { + code: `fc.assert(property, { numRuns: 300 })`, + errors: [{ messageId: `literalRuns` }], + }, + { + code: `fcTest.prop([history], { numRuns: 200 })('checks the history', check)`, + errors: [{ messageId: `literalRuns` }], + }, { // A comment does not grant an exception. code: ` diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js index 19c0f6313a..b35b111dec 100644 --- a/scripts/eslint-plugin-oracle/rules/no-disabled.js +++ b/scripts/eslint-plugin-oracle/rules/no-disabled.js @@ -8,33 +8,65 @@ * disables nothing, such as the test that a replay registers when it does * not select its property. * - A test that skips itself through its context, as in `ctx.skip()`. - * - `.skipIf(condition)` or `.runIf(condition)`, unless the call is exactly - * `.skipIf(oracleReplayRequested())`. A replay may stand a test aside, and - * nothing else may switch an oracle test off. The shared helper fixes the - * polarity, so a condition cannot turn the test off in ordinary runs. + * - `.skipIf(condition)` or `.runIf(condition)`, unless the call is + * `.skipIf(oracleReplayRequested())` or `.skipIf(oracleReplaySkips(...))`. + * A replay may stand a test aside, and nothing else may switch an oracle + * test off. The shared helpers in oracle-config are false in an ordinary + * run, so no condition built from them can turn a test off there. + * - A test modifier passed around as a value, as in + * `const run = replaying ? it.skip : it`. The rule cannot see what such an + * alias switches off. `oracleReplayTest(test, property)` selects a replay. + * - A test registered under a condition, or after an early `return`, + * unless the condition is a shared replay helper or its negation. An early + * `throw` fails the run loudly, so it guards nothing. * - Playwright's runtime `test.skip()` or `test.fixme()` inside a test, * unless its condition is `browserName !== ''`: a control that * only one browser supports. * - A generated property limited to one run that pins no history: a literal * `numRuns` of 1 or 0, or a base of 1 passed to the run helpers. One random - * history is not a generated property. A replay pins its history with a - * `path` beside `numRuns`, and a witness with a seed that is a number - * literal or a constant bound to one. `oraclePropertyOptions` returns one - * run for a path replay, so a campaign needs no one-run option of its own. + * history is not a generated property. A witness pins its history with a + * recorded `path` (a literal, or a failure's `counterexamplePath`) or with + * a seed that is a number literal or a constant bound to one. A replay from + * configuration takes its options from `oraclePropertyOptions`, which + * returns one run for a path replay. + * - A literal run count above 1 that pins no history. It does not scale with + * `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER`, so `oracleRuns` must wrap it. * * The rule has no inline exception. A known gap is pinned with `it.fails` on * a fixed seed, not skipped. * - * The rule cannot see a run count lowered to any value above 1, a `path` or - * seed read from configuration that is absent in ordinary runs, a skip - * reached through an alias such as `const run = it.skip`, or a registration - * inside an `if`. Reviewers still check those. + * The rule cannot see a run count lowered to any value above 1, a seed read + * from configuration that is absent in ordinary runs, a test function renamed + * before its modifier is read (`const t = it; t.skip(...)`), or a call to a + * local helper that registers tests under a condition. Reviewers still check + * those. */ const testRoots = new Set([`describe`, `fcTest`, `it`, `suite`, `test`]) const disablingModifiers = new Set([`fixme`, `skip`, `todo`]) const conditionalModifiers = new Set([`runIf`, `skipIf`]) -const replaySelection = `oracleReplayRequested` +const aliasModifiers = new Set([ + ...disablingModifiers, + ...conditionalModifiers, + `only`, +]) +const replayConditions = new Set([`oracleReplayRequested`, `oracleReplaySkips`]) +// The members that keep a call a test registration. Playwright's `test` also +// has calls such as `test.info()` and `test.step()` that register nothing. +const registrationMembers = new Set([ + ...aliasModifiers, + `concurrent`, + `describe`, + `each`, + `fails`, + `parallel`, + `prop`, + `sequential`, + `serial`, +]) +// oracle-config implements replay selection, so its options may carry a +// replay path from configuration. +const replaySelectionModule = /(^|[/\\])oracle-config\.ts$/ const runHelpers = new Set([ `oraclePropertyOptions`, `oracleRandomParameters`, @@ -94,30 +126,112 @@ function registrationCondition(callee) { return undefined } -/** Exactly `.skipIf(oracleReplayRequested())`. */ -function isReplaySelection({ name, args }, sourceCode) { - const [condition] = args +/** + * A call to one of the shared oracle-config helpers in `names`. The binding + * must be the import, not a local function with the same name. + */ +function isSharedHelperCall(node, names, sourceCode) { if ( - name !== `skipIf` || - args.length !== 1 || - condition.type !== `CallExpression` || - condition.callee.type !== `Identifier` || - condition.callee.name !== replaySelection || - condition.arguments.length > 0 + node?.type !== `CallExpression` || + node.callee.type !== `Identifier` || + !names.has(node.callee.name) ) { return false } - // The helper must be the shared one, not a local function of that name. - const definition = findVariable( - sourceCode.getScope(condition), - replaySelection, - )?.defs[0] + const definition = findVariable(sourceCode.getScope(node), node.callee.name) + ?.defs[0] return ( definition?.type === `ImportBinding` && /(^|\/)oracle-config(\.js)?$/.test(definition.parent.source.value) ) } +/** A shared replay condition, or its negation. */ +function isReplayCondition(node, sourceCode) { + const condition = + node.type === `UnaryExpression` && node.operator === `!` + ? node.argument + : node + return isSharedHelperCall(condition, replayConditions, sourceCode) +} + +/** Exactly `.skipIf()`, not negated. */ +function isReplaySelection({ name, args }, sourceCode) { + return ( + name === `skipIf` && + args.length === 1 && + isSharedHelperCall(args[0], replayConditions, sourceCode) + ) +} + +/** + * Whether a statement always leaves its block without failing. A `throw` + * fails the run loudly, so it guards nothing. + */ +function exits(statement) { + if (statement === null || statement === undefined) return false + if ( + statement.type === `ReturnStatement` || + statement.type === `ContinueStatement` || + statement.type === `BreakStatement` + ) { + return true + } + if (statement.type === `BlockStatement`) { + return exits(statement.body.at(-1)) + } + if (statement.type === `IfStatement`) { + return exits(statement.consequent) && exits(statement.alternate) + } + return false +} + +/** + * The conditions that decide whether `node` runs: every enclosing `if`, + * conditional, or logical branch, and every earlier `if` in an enclosing + * block that leaves the block. + */ +function guardingConditions(node) { + const conditions = [] + let child = node + for (let parent = node.parent; parent; parent = parent.parent) { + if ( + (parent.type === `IfStatement` || + parent.type === `ConditionalExpression`) && + child !== parent.test + ) { + conditions.push(parent.test) + } else if (parent.type === `LogicalExpression` && child === parent.right) { + conditions.push(parent.left) + } else if ( + parent.type === `BlockStatement` || + parent.type === `Program` || + parent.type === `SwitchCase` + ) { + const body = + parent.type === `SwitchCase` ? parent.consequent : parent.body + for (const statement of body.slice(0, body.indexOf(child))) { + if (statement.type === `IfStatement` && exits(statement.consequent)) { + conditions.push(statement.test) + } + } + } + child = parent + } + return conditions +} + +/** A `path` that a witness recorded: a literal, or a failure's path. */ +function isRecordedPath(node) { + if (node.type === `TemplateLiteral`) return node.expressions.length === 0 + if (node.type === `Literal`) return typeof node.value === `string` + return ( + node.type === `MemberExpression` && + !node.computed && + node.property.name === `counterexamplePath` + ) +} + /** Playwright's `browserName !== ''`. */ function isBrowserCondition(node) { return ( @@ -197,9 +311,7 @@ function isPinnedOrObservation(object, sourceCode) { const option = (name) => object.properties.find((property) => propertyName(property) === name) const path = option(`path`) - if (path !== undefined && sourceCode.getText(path.value) !== `undefined`) { - return true - } + if (path !== undefined && isRecordedPath(path.value)) return true const seed = option(`seed`) if (seed !== undefined && isFixedSeed(seed.value, sourceCode)) return true const { parent } = object @@ -250,8 +362,11 @@ export default { messages: { only: `\`.only\` runs only this test, so every other law in the file stops running. Remove it before committing.`, skip: `\`.{{name}}\` stops this test from enforcing its law. Remove it. To keep a known gap visible, pin a failing history with \`it.fails\` and a fixed seed.`, - conditional: `\`.{{name}}\` can switch this test off in ordinary runs. Only \`.skipIf(oracleReplayRequested())\` may switch an oracle test off, so that a replay can stand it aside. Remove the condition.`, - singleRun: `A generated property with one run that pins no history checks one random history. Pass a base run count to oracleRuns or oraclePropertyOptions. A replay passes \`path\`, and a witness passes a seed that is a number literal or a constant.`, + conditional: `\`.{{name}}\` can switch this test off in ordinary runs. Only \`.skipIf(oracleReplayRequested())\` or \`.skipIf(oracleReplaySkips(property))\` may switch an oracle test off, so that a replay can stand it aside. Remove the condition.`, + alias: `\`.{{name}}\` is passed around as a value, so the rule cannot see what it switches off. Call it directly, or select a replay with \`oracleReplayTest(test, property)\` from oracle-config.`, + guarded: `This test registers only under a condition, so it can stop running in ordinary runs. Register it unconditionally. A replay selection uses \`oracleReplayRequested()\` or \`oracleReplaySkips(property)\` from oracle-config, or \`oracleReplayTest(test, property)\`.`, + singleRun: `A generated property with one run that pins no history checks one random history. Pass a base run count to oracleRuns or oraclePropertyOptions, which also selects a replay. A witness passes a recorded \`path\` or a seed that is a number literal or a constant.`, + literalRuns: `A literal run count does not scale with TANSTACK_DB_ORACLE_RUNS_MULTIPLIER. Wrap it in oracleRuns, or pass it to oraclePropertyOptions.`, }, }, create(context) { @@ -292,7 +407,19 @@ export default { } return } - if (!testRoots.has(root)) return + if ( + !testRoots.has(root) || + !names.every((member) => registrationMembers.has(member)) + ) { + return + } + const unguarded = guardingConditions(node).every((condition) => + isReplayCondition(condition, sourceCode), + ) + if (!unguarded) { + context.report({ node, messageId: `guarded` }) + return + } if (names.includes(`only`)) { context.report({ node: node.callee, messageId: `only` }) return @@ -313,14 +440,42 @@ export default { context.report({ node, messageId: `skip`, data: { name: disabled } }) } }, + MemberExpression(node) { + // Report a chain such as `fcTest.skip` or `it.skip.prop` once, at its + // outermost member, when nothing calls it. + if ( + node.parent.type === `MemberExpression` && + node.parent.object === node + ) { + return + } + if ( + node.parent.type === `CallExpression` && + node.parent.callee === node + ) { + return + } + const { root, names } = calleeChain(node) + const name = names.find((member) => aliasModifiers.has(member)) + if (testRoots.has(root) && name !== undefined) { + context.report({ node, messageId: `alias`, data: { name } }) + } + }, Property(node) { if ( - propertyName(node) === `numRuns` && - isSingleRunLiteral(node.value) && - !isPinnedRunCount(node.value) + replaySelectionModule.test(context.filename) || + propertyName(node) !== `numRuns` || + !isNumberLiteral(node.value) || + isPinnedRunCount(node.value) ) { - context.report({ node, messageId: `singleRun` }) + return } + context.report({ + node, + messageId: isSingleRunLiteral(node.value) + ? `singleRun` + : `literalRuns`, + }) }, } }, From 49bcf2a2f34578b63b333ccd3ff1d1747d2e8731 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 13:17:53 -0600 Subject: [PATCH 6/8] refactor(oracles): select every oracle replay through shared helpers Move each oracle's replay selection onto oracleReplayRequested, oracleReplaySkips, oracleReplayTest, and oraclePropertyOptions, so the extended oracle/no-disabled rule passes everywhere. In an ordinary run, every file runs the same tests with the same seeds and run counts; literal run counts are wrapped in oracleRuns, which equals the count at multiplier 1. Replay changes: - Oracles with their own replay variables now use TANSTACK_DB_ORACLE_SEED, _PATH, and _PROPERTY: utils (utils.N), comparison (comparison.N), IndexedDB cross-tab history, db-ivm index refinement, offline KeyScheduler, Electric history and pg-serializer, SQLite core adapter, Node expression index, and op-sqlite. ORACLE.md and the coverage map show the new replay commands. - A seed without a path reseeds random campaigns and keeps fixed ones, matching the shared semantics. - Some fixed or unit tests now stand aside during any path replay, where they used to run during some replays. - Calibration witnesses register through oracleReplayTest and keep TANSTACK_DB_ORACLE_CALIBRATION as their fault switch. Other changes: - The replay guard's calibration fixture is renamed to replay-guard-calibration.fixture.test.ts. It is a fixture, not an oracle. - The includes transition matrix registers over a filtered table, not under `continue`. - The PowerSync correctness oracle now fails loudly when no SQLite binding loads, where it used to skip itself silently. Refs #2234 Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 7 +- .../2026-09-30-guide-portfolio.md | 2 +- ...hash-failure-retry-oracle.property.test.ts | 3 +- .../tests/hash-graph-oracle.property.test.ts | 3 +- .../hash-identity-oracle.property.test.ts | 3 +- .../hash-mixed-graph-oracle.property.test.ts | 3 +- .../db-ivm/tests/hash-oracle.property.test.ts | 3 +- .../index-refinement-oracle.property.test.ts | 72 +- ...ltiset-consolidate-oracle.property.test.ts | 3 +- .../murmur-stream-oracle.property.test.ts | 3 +- .../reduce-fast-path-oracle.property.test.ts | 3 +- .../topKWithFractionalIndex-oracle.test.ts | 7 +- .../tests/persisted-oracle.test.ts | 65 +- .../tests/sqlite-core-adapter-oracle.test.ts | 315 +- ...tadata-publication-oracle.property.test.ts | 46 +- ...-lifecycle-history-oracle.property.test.ts | 13 +- ...tion-subscription-lifecycle-oracle.test.ts | 6 +- .../tests/comparison-oracle.property.test.ts | 59 +- .../db/tests/cursor-oracle.property.test.ts | 12 +- ...rce-reconciliation-oracle.property.test.ts | 10 +- ...at-change-tracking-oracle.property.test.ts | 22 +- packages/db/tests/indexed-db/ORACLE.md | 4 +- .../cross-tab-history-oracle.test.ts | 42 +- .../indexed-db/persistence-oracle.test.ts | 5 +- ...age-value-fidelity-oracle.property.test.ts | 27 +- .../db/tests/notification-laws-oracle.test.ts | 7 +- packages/db/tests/oracle-replay-manifest.ts | 6 +- packages/db/tests/oracle-replay.test.ts | 2 +- .../proxy-detachment-contract-oracle.test.ts | 7 +- packages/db/tests/proxy-oracle.test.ts | 3 +- .../proxy-revert-oracle.property.test.ts | 11 +- ...ncludes-collection-oracle.property.test.ts | 143 +- ...-cross-formulation-oracle.property.test.ts | 58 +- ...ncludes-optimistic-oracle.property.test.ts | 18 +- .../query/includes-oracle.property.test.ts | 46 +- .../query/includes-temporal-oracle.test.ts | 10 +- .../query/ir-stable-identity-oracle.test.ts | 3 +- .../join-result-key-oracle.property.test.ts | 26 +- .../pooled-live-query-oracle.property.test.ts | 25 +- ...dicate-publication-oracle.property.test.ts | 26 +- ... replay-guard-calibration.fixture.test.ts} | 0 .../db/tests/utils-oracle.property.test.ts | 62 +- ...ectric-descriptor-isolation-oracle.test.ts | 45 +- .../tests/electric-oracle.property.test.ts | 4918 +++++++++-------- ...ctric-sdk-delivery-oracle.property.test.ts | 14 +- .../pg-serializer-oracle.property.test.ts | 251 +- .../tests/expression-index-oracle.test.ts | 419 +- .../KeyScheduler-oracle.property.test.ts | 72 +- packages/offline-transactions/tsconfig.json | 2 +- .../tests/correctness-oracle.test.ts | 11 +- .../tests/op-sqlite-driver-oracle.test.ts | 323 +- 51 files changed, 3566 insertions(+), 3680 deletions(-) rename packages/db/tests/{oracle-replay.fixture.test.ts => replay-guard-calibration.fixture.test.ts} (100%) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a085dd75d9..30653cc742 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2296,11 +2296,12 @@ properties have separate environment variables; inspect their test headers. Do not assume the core multiplier reaches them. The Node expression-index oracle runs matching fixed-seed and seedless-random -campaigns by default. Supplying both replay values selects only the requested -seed and shrink path: +campaigns by default. A path replay of its property selects only the +requested seed and shrink path: ```sh -TANSTACK_DB_WS5A_SEED=1659005 TANSTACK_DB_WS5A_PATH=0 \ +TANSTACK_DB_ORACLE_SEED=1659005 TANSTACK_DB_ORACLE_PATH=0 \ +TANSTACK_DB_ORACLE_PROPERTY=sqlite-persistence.expression-index \ pnpm --filter @tanstack/node-db-sqlite-persistence test:oracles ``` diff --git a/docs/contributing/oracle-reviews/2026-09-30-guide-portfolio.md b/docs/contributing/oracle-reviews/2026-09-30-guide-portfolio.md index af65dccb7f..2146271cba 100644 --- a/docs/contributing/oracle-reviews/2026-09-30-guide-portfolio.md +++ b/docs/contributing/oracle-reviews/2026-09-30-guide-portfolio.md @@ -115,7 +115,7 @@ Revised means changed in executable revision 7e4266bb; retained means the assign | packages/db/tests/live-query-observer-history.property.test.ts | revised | | packages/db/tests/mutation-handler-type-oracle.test-d.ts | revised | | packages/db/tests/optimistic-transaction-oracle.property.test.ts | revised | -| packages/db/tests/oracle-replay.fixture.test.ts | retained | +| packages/db/tests/replay-guard-calibration.fixture.test.ts | retained | | packages/db/tests/oracle-replay.test.ts | revised | | packages/db/tests/paced-mutations-oracle.test.ts | retained | | packages/db/tests/query/cold-join-reconciliation-oracle.test.ts | revised | diff --git a/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts b/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts index 183e785fd6..d1420922d4 100644 --- a/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' +import { oracleRuns } from '../../db/tests/oracle-config.js' /** * A failed structural traversal must publish no reusable hash state. @@ -136,7 +137,7 @@ describe(`getter failure does not publish partial structural hashes`, () => { expectRetryHistory(history), ), { - numRuns: 100, + numRuns: oracleRuns(100), ...(seed === undefined ? {} : { seed }), ...(path === undefined ? {} : { path }), }, diff --git a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts index a6c23c45ee..9f0fe0413a 100644 --- a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' +import { oracleRuns } from '../../db/tests/oracle-config.js' /** * Structural hash follows the reachable value graph, not object identity. @@ -182,7 +183,7 @@ describe(`structural hash graph boundary`, () => { expectGraphHash(edges) }), { - numRuns: 300, + numRuns: oracleRuns(300), ...(campaign.seed === undefined ? {} : { seed: campaign.seed }), ...(campaign.path === undefined ? {} : { path: campaign.path }), }, diff --git a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts index d64786f9f1..2b48d61a22 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -8,6 +8,7 @@ import { hash, registerOpaqueHash, } from '../src/hashing/hash.js' +import { oracleRuns } from '../../db/tests/oracle-config.js' import { captureHashSession } from './hash-session' /** @@ -816,7 +817,7 @@ function runOptions(seed: number | undefined) { ) throw new Error(`TANSTACK_DB_IVM_HASH_IDENTITY_SEED must be an integer`) return { - numRuns: 300, + numRuns: oracleRuns(300), ...(seed === undefined ? {} : { seed }), ...(replayPath === undefined ? {} : { path: replayPath }), } diff --git a/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts b/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts index b95bca5a9b..436df37999 100644 --- a/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' +import { oracleRuns } from '../../db/tests/oracle-config.js' /** * Mixed-carrier graphs extend the established structural-hash graph law across @@ -271,7 +272,7 @@ describe(`mixed-carrier structural graph laws`, () => { const hashValue = replayFault === `accept-cycles` ? () => 0 : hash const property = mixedGraphProperty(hashValue, () => checked++) fc.assert(property, { - numRuns: 150, + numRuns: oracleRuns(150), ...(campaign.seed === undefined ? {} : { seed: campaign.seed }), ...(campaign.path === undefined ? {} : { path: campaign.path }), }) diff --git a/packages/db-ivm/tests/hash-oracle.property.test.ts b/packages/db-ivm/tests/hash-oracle.property.test.ts index bb6c0db903..909a8f8cf8 100644 --- a/packages/db-ivm/tests/hash-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-oracle.property.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' +import { oracleRuns } from '../../db/tests/oracle-config.js' import type { Arbitrary } from 'fast-check' /** @@ -113,7 +114,7 @@ function hashProp]>(arbitraries: { ) throw new Error(`TANSTACK_DB_IVM_HASH_SEED must be a 32-bit integer`) fc.assert(property, { - numRuns: 100, + numRuns: oracleRuns(100), ...(seed === undefined ? {} : { seed }), ...(valueReplayPath === undefined ? {} : { path: valueReplayPath }), }) diff --git a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts index a78621207a..0901900f6f 100644 --- a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts +++ b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts @@ -54,6 +54,11 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { Index } from '../src/indexes.js' +import { + oraclePropertyOptions, + oracleReplayTest, + oracleRuns, +} from '../../db/tests/oracle-config.js' type Prefix = number | string | bigint type Value = [Prefix, number | string] | number | string @@ -228,58 +233,45 @@ function expectRefinement( // --------------------------------------------------------------------------- // Campaigns. The fixed and random campaigns run the same property, grammar, -// check, and budget. `TANSTACK_DB_IVM_INDEX_SEED` and -// `TANSTACK_DB_IVM_INDEX_PATH` select a direct replay. +// check, and budget. A seed alone reseeds the random campaign. A path replay +// of INDEX_REFINEMENT_PROPERTY runs its recorded history, and every other +// test in this file stands aside. -const replaySeed = process.env.TANSTACK_DB_IVM_INDEX_SEED -const replayPath = process.env.TANSTACK_DB_IVM_INDEX_PATH -const campaigns = - replaySeed === undefined && replayPath === undefined - ? [ - { name: `20260930`, seed: 20260930 as number | undefined }, - { name: `random`, seed: undefined }, - ] - : [ - { - name: `replay`, - seed: replaySeed === undefined ? undefined : Number(replaySeed), - }, - ] +const INDEX_REFINEMENT_PROPERTY = `ivm.index-refinement` describe(`Index refinement oracle`, () => { - if (replaySeed === undefined && replayPath === undefined) { - for (const { name, additions } of pinnedHistories) { - it(`matches the multiset model when ${name}`, () => { - expectRefinement(additions) - expectRefinement(additions, `prefixIdentity`) - }) - } + for (const { name, additions } of pinnedHistories) { + oracleReplayTest(it)(`matches the multiset model when ${name}`, () => { + expectRefinement(additions) + expectRefinement(additions, `prefixIdentity`) + }) } - for (const { name, seed } of campaigns) { - it(`matches the multiset model across generated additions (${name})`, () => { - if (replayPath !== undefined && replaySeed === undefined) - throw new Error(`TANSTACK_DB_IVM_INDEX_PATH requires a seed`) - if ( - replaySeed !== undefined && - (replaySeed.trim() === `` || - typeof seed !== `number` || - !Number.isSafeInteger(seed)) + oracleReplayTest(it)( + `matches the multiset model across generated additions (20260930)`, + () => { + fc.assert( + fc.property(historyArbitrary, (additions) => { + expectRefinement(additions) + expectRefinement(additions, `prefixIdentity`) + }), + { numRuns: oracleRuns(300), seed: 20260930 }, ) - throw new Error(`TANSTACK_DB_IVM_INDEX_SEED must be an integer`) + }, + ) + + oracleReplayTest(it, INDEX_REFINEMENT_PROPERTY)( + `matches the multiset model across generated additions (random or replayed)`, + () => { fc.assert( fc.property(historyArbitrary, (additions) => { expectRefinement(additions) expectRefinement(additions, `prefixIdentity`) }), - { - numRuns: 300, - ...(seed === undefined ? {} : { seed }), - ...(replayPath === undefined ? {} : { path: replayPath }), - }, + oraclePropertyOptions(300, INDEX_REFINEMENT_PROPERTY), ) - }) - } + }, + ) // Positive execution witness: generated histories reach a NaN prefix that // cancels after another value joined its key. diff --git a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts index 38ca1783e3..f047d3787d 100644 --- a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts +++ b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { MultiSet } from '../src/multiset.js' +import { oracleRuns } from '../../db/tests/oracle-config.js' import type { MultiSetArray } from '../src/multiset.js' /** @@ -556,7 +557,7 @@ describe(`MultiSet consolidation oracle`, () => { fc.assert( fc.property(historyArb, (history) => expectConsolidation(history)), { - numRuns: 300, + numRuns: oracleRuns(300), ...(seed === undefined ? {} : { seed }), ...(replayPath === undefined ? {} : { path: replayPath }), }, diff --git a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts index e867686d46..7e736c28b5 100644 --- a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts +++ b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { fc } from '@fast-check/vitest' +import { oracleRuns } from '../../db/tests/oracle-config.js' /** * # Does the murmur stream hash the bytes it is given? @@ -86,7 +87,7 @@ describe(`MurmurHashStream`, () => { fc.property(fc.array(chunk, { minLength: 1, maxLength: 8 }), (chunks) => { expect(digests(Current, chunks)).toEqual(digests(Reference, chunks)) }), - { numRuns: 2000 }, + { numRuns: oracleRuns(2000) }, ) }) diff --git a/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts b/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts index caa78e055d..b75d7346fd 100644 --- a/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts +++ b/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts @@ -4,6 +4,7 @@ import { D2 } from '../../src/d2.js' import { MultiSet } from '../../src/multiset.js' import { reduce } from '../../src/operators/reduce.js' import { output } from '../../src/operators/output.js' +import { oracleRuns } from '../../../db/tests/oracle-config.js' /** * # Does the reduce fast path emit what the general path emits? @@ -137,7 +138,7 @@ describe(`reduce fast path`, () => { expect(emitted(runs, false)).toEqual(emitted(runs, true)) }, ), - { numRuns: 1000 }, + { numRuns: oracleRuns(1000) }, ) }) }) diff --git a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts index 82f2e236e3..38f34828cb 100644 --- a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts @@ -11,6 +11,7 @@ import { assertOnlyKeysAffected, compareFractionalIndex, } from '../test-utils.js' +import { oracleRuns } from '../../../db/tests/oracle-config.js' import { TopKMessageTracker, TopKRelation } from './topk-relation-oracle.js' // Helper function to check if indices are in lexicographic order @@ -163,7 +164,7 @@ describe.each([ ), { seed, - numRuns: 30, + numRuns: oracleRuns(30), examples: [ [ false, @@ -243,7 +244,7 @@ describe.each([ ), { seed, - numRuns: 100, + numRuns: oracleRuns(100), examples: [ [ 1, @@ -361,7 +362,7 @@ describe.each([ ), { seed, - numRuns: 100, + numRuns: oracleRuns(100), examples: [ [ { diff --git a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts index 647a9520e4..225e6cba9a 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -17,8 +17,9 @@ import { } from '@tanstack/db' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../../db/tests/oracle-config.js' import { IndeterminateCommitError, @@ -210,19 +211,17 @@ function expectExactSnapshot( for (const [key, row] of expected) expect(actual.get(key)).toStrictEqual(row) } -const requestedOracleReplayProperty = readOracleRunConfig().replayProperty -const describeUnlessOracleReplay = - requestedOracleReplayProperty === undefined || - requestedOracleReplayProperty === `persistence.retained-demand` || - requestedOracleReplayProperty === `persistence.generation-notice` || - requestedOracleReplayProperty === `persistence.follower-remote-demand` - ? describe - : describe.skip +// A path replay of one of these properties runs this suite. A replay of any +// other property stands it aside. +const describeUnlessOracleReplay = oracleReplayTest( + describe, + `persistence.retained-demand`, + `persistence.generation-notice`, + `persistence.follower-remote-demand`, +) -const itUnlessAnyOracleReplay = - requestedOracleReplayProperty === undefined ? it : it.skip -const describeUnlessAnyOracleReplay = - requestedOracleReplayProperty === undefined ? describe : describe.skip +const itUnlessAnyOracleReplay = oracleReplayTest(it) +const describeUnlessAnyOracleReplay = oracleReplayTest(describe) const hydrationScopeModes = [ `none`, @@ -3975,7 +3974,7 @@ function generatedPersistenceGrammarSamples(): ReadonlyArray<{ if (match === undefined) throw new Error(`missing grammar: ${property}`) return match } - const sampleOptions = (seed: number) => ({ seed, numRuns: 4 }) + const sampleOptions = (seed: number) => ({ seed, numRuns: oracleRuns(4) }) return [ { grammar: grammar(`sqlite-persistence.source-fifo-order`), @@ -4102,31 +4101,27 @@ function registerGeneratedPersistenceProperty(options: { run: (value: Value) => Promise }): void { const { property, title, arbitrary, fixedSeed, run } = options - if (requestedOracleReplayProperty === undefined) { - fcTest.prop([arbitrary], { seed: fixedSeed, numRuns: oracleRuns(4) })( - `${title} (fixed)`, - run, - ) - fcTest.prop([arbitrary], oraclePropertyOptions(4, property))( - `${title} (random)`, - run, - ) - } else if (requestedOracleReplayProperty === property) { - fcTest.prop([arbitrary], oraclePropertyOptions(4, property))( - `${title} (replay)`, - run, - ) - } + oracleReplayTest(fcTest).prop([arbitrary], { + seed: fixedSeed, + numRuns: oracleRuns(4), + })(`${title} (fixed)`, run) + oracleReplayTest(fcTest, property).prop( + [arbitrary], + oraclePropertyOptions(4, property), + )(`${title} (random)`, run) } -const describeDurabilityOracles = - requestedOracleReplayProperty === undefined || - requestedOracleReplayProperty.startsWith(`sqlite-persistence.`) - ? describe - : describe.skip +const describeDurabilityOracles = oracleReplayTest( + describe, + `sqlite-persistence.source-fifo-order`, + `sqlite-persistence.abort-graph`, + `sqlite-persistence.owner-isolation`, + `sqlite-persistence.open-transaction-boundary`, + `sqlite-persistence.request-local-failure-buffered-source`, +) describeDurabilityOracles(`generated persistence durability oracles`, () => { - if (requestedOracleReplayProperty === undefined) { + if (!oracleReplayRequested()) { it(`reconstructs the full grammar and rejects ablated, out-of-range, and foreign histories`, () => { for (const { grammar, diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts index 16633978b4..647469bb3f 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts @@ -9,6 +9,10 @@ import { inspect, promisify } from 'node:util' import { fc } from '@fast-check/vitest' import { afterEach, describe, expect, it } from 'vitest' import { IR, createCollection } from '@tanstack/db' +import { + oraclePropertyOptions, + oracleReplayTest, +} from '../../db/tests/oracle-config.js' import { SQLiteCorePersistenceAdapter, createPersistedTableName, @@ -2527,138 +2531,136 @@ export function runSQLiteCoreAdapterContractSuite( * is removed after evidence capture. The failure reports first and reduced * histories plus a verified fast-check seed/path replay. */ - it(`resets collection metadata with its persisted baseline across generated adapter reopen histories`, async () => { - const historyArbitrary = fc - .constantFrom( - `compatible-reopen`, - `schema-reset`, - `partial-restore`, - `external-row-loss`, - ) - .chain((transition) => - fc.record({ - fromSchemaVersion: fc.integer({ min: 1, max: 4 }), - rows: fc - .uniqueArray(fc.integer({ min: 0, max: 20 }), { - minLength: - transition === `partial-restore` - ? 2 - : transition === `external-row-loss` - ? 1 - : 0, - maxLength: 4, - }) - .map((ids) => - ids - .sort((left, right) => left - right) - .map((id) => ({ - id: String(id), - title: `row-${id}`, - createdAt: `2026-01-01T00:00:00.000Z`, - score: id, - })), + oracleReplayTest(it, `sqlite-resume.baseline-lineage`)( + `resets collection metadata with its persisted baseline across generated adapter reopen histories`, + async () => { + const historyArbitrary = fc + .constantFrom( + `compatible-reopen`, + `schema-reset`, + `partial-restore`, + `external-row-loss`, + ) + .chain((transition) => + fc.record({ + fromSchemaVersion: fc.integer({ min: 1, max: 4 }), + rows: fc + .uniqueArray(fc.integer({ min: 0, max: 20 }), { + minLength: + transition === `partial-restore` + ? 2 + : transition === `external-row-loss` + ? 1 + : 0, + maxLength: 4, + }) + .map((ids) => + ids + .sort((left, right) => left - right) + .map((id) => ({ + id: String(id), + title: `row-${id}`, + createdAt: `2026-01-01T00:00:00.000Z`, + score: id, + })), + ), + resumeKind: fc.constantFrom(`none`, `reset`, `resume`), + transition: fc.constant(transition), + reopensBeforeTransition: fc.integer({ min: 0, max: 2 }), + reopensAfterTransition: fc.integer({ min: 0, max: 2 }), + unrelatedMetadataKeys: fc.uniqueArray( + fc.constantFrom(`gc`, `provider`, `custom`), + { maxLength: 3 }, ), - resumeKind: fc.constantFrom(`none`, `reset`, `resume`), - transition: fc.constant(transition), - reopensBeforeTransition: fc.integer({ min: 0, max: 2 }), - reopensAfterTransition: fc.integer({ min: 0, max: 2 }), - unrelatedMetadataKeys: fc.uniqueArray( - fc.constantFrom(`gc`, `provider`, `custom`), - { maxLength: 3 }, - ), - }), - ) + }), + ) - const seedText = - process.env.TANSTACK_DB_SQLITE_ORACLE_SEED ?? String(1659) - const seed = Number(seedText) - const path = process.env.TANSTACK_DB_SQLITE_ORACLE_PATH - const runsText = process.env.TANSTACK_DB_SQLITE_ORACLE_RUNS ?? String(24) - const numRuns = Number(runsText) - if (!Number.isSafeInteger(seed)) { - throw new Error(`TANSTACK_DB_SQLITE_ORACLE_SEED must be an integer`) - } - if (!Number.isSafeInteger(numRuns) || numRuns < 1) { - throw new Error(`TANSTACK_DB_SQLITE_ORACLE_RUNS must be positive`) - } - if (path !== undefined && !/^\d+(?::\d+)*$/.test(path)) { - throw new Error( - `TANSTACK_DB_SQLITE_ORACLE_PATH must be a numeric shrink path`, + // A seed-only shared run reseeds this campaign. Ordinary runs keep the + // fixed seed 1659, and a path replay runs once from its recorded path. + const parameters = oraclePropertyOptions( + 24, + `sqlite-resume.baseline-lineage`, ) - } - let originalFailure: ResetResumeOracleViolation | undefined - const property = fc.asyncProperty(historyArbitrary, async (history) => { - try { - await observeResetResumeHistory(history, harnessFactory) - } catch (error) { - if ( - originalFailure === undefined && - error instanceof ResetResumeOracleViolation - ) { - originalFailure = error + let originalFailure: ResetResumeOracleViolation | undefined + const property = fc.asyncProperty(historyArbitrary, async (history) => { + try { + await observeResetResumeHistory(history, harnessFactory) + } catch (error) { + if ( + originalFailure === undefined && + error instanceof ResetResumeOracleViolation + ) { + originalFailure = error + } + throw error } - throw error + }) + const failure = await fc.check(property, { + ...parameters, + seed: parameters.seed ?? 1659, + ...(parameters.path === undefined ? {} : { endOnFailure: true }), + }) + if (!failure.failed) return + if ( + !(failure.errorInstance instanceof ResetResumeOracleViolation) || + originalFailure === undefined || + failure.counterexamplePath === null + ) { + throw failure.errorInstance } - }) - const failure = await fc.check(property, { - seed, - numRuns, - ...(path === undefined ? {} : { path, endOnFailure: true }), - }) - if (!failure.failed) return - if ( - !(failure.errorInstance instanceof ResetResumeOracleViolation) || - originalFailure === undefined || - failure.counterexamplePath === null - ) { - throw failure.errorInstance - } - if (!hasSameResetResumeFailure(originalFailure, failure.errorInstance)) { - throw new Error( - `Shrinking changed the reset/resume law or observation checkpoint`, + if ( + !hasSameResetResumeFailure(originalFailure, failure.errorInstance) + ) { + throw new Error( + `Shrinking changed the reset/resume law or observation checkpoint`, + ) + } + + const replay = await fc.check( + fc.asyncProperty(historyArbitrary, async (history) => { + await observeResetResumeHistory(history, harnessFactory) + }), + { + seed: failure.seed, + path: failure.counterexamplePath, + endOnFailure: true, + }, ) - } + if ( + !replay.failed || + !(replay.errorInstance instanceof ResetResumeOracleViolation) || + JSON.stringify(replay.counterexample) !== + JSON.stringify(failure.counterexample) || + !hasSameResetResumeFailure( + failure.errorInstance, + replay.errorInstance, + ) + ) { + throw new Error( + `Reset/resume oracle replay did not reproduce the intended violation`, + ) + } - const replay = await fc.check( - fc.asyncProperty(historyArbitrary, async (history) => { - await observeResetResumeHistory(history, harnessFactory) - }), - { - seed: failure.seed, - path: failure.counterexamplePath, - endOnFailure: true, - }, - ) - if ( - !replay.failed || - !(replay.errorInstance instanceof ResetResumeOracleViolation) || - JSON.stringify(replay.counterexample) !== - JSON.stringify(failure.counterexample) || - !hasSameResetResumeFailure(failure.errorInstance, replay.errorInstance) - ) { + const reducedFailure = failure.errorInstance throw new Error( - `Reset/resume oracle replay did not reproduce the intended violation`, + `Reset/resume baseline-lineage violation. ` + + `seed=${failure.seed} path=${failure.counterexamplePath} ` + + `law=${reducedFailure.law} ` + + `discriminant=${reducedFailure.discriminant} ` + + `checkpoint=${reducedFailure.observation.checkpoint} ` + + `originalTrace=${JSON.stringify(originalFailure.history)} ` + + `reducedTrace=${JSON.stringify(reducedFailure.history)} ` + + `expected=${JSON.stringify(reducedFailure.expected)} ` + + `actual=${JSON.stringify(reducedFailure.actual)} ` + + `observation=${JSON.stringify(reducedFailure.observation)} ` + + `replay=verified ` + + `cleanup=${reducedFailure.cleanupEvidence}`, + { cause: reducedFailure }, ) - } - - const reducedFailure = failure.errorInstance - throw new Error( - `Reset/resume baseline-lineage violation. ` + - `seed=${failure.seed} path=${failure.counterexamplePath} ` + - `law=${reducedFailure.law} ` + - `discriminant=${reducedFailure.discriminant} ` + - `checkpoint=${reducedFailure.observation.checkpoint} ` + - `originalTrace=${JSON.stringify(originalFailure.history)} ` + - `reducedTrace=${JSON.stringify(reducedFailure.history)} ` + - `expected=${JSON.stringify(reducedFailure.expected)} ` + - `actual=${JSON.stringify(reducedFailure.actual)} ` + - `observation=${JSON.stringify(reducedFailure.observation)} ` + - `replay=verified ` + - `cleanup=${reducedFailure.cleanupEvidence}`, - { cause: reducedFailure }, - ) - }, 120_000) + }, + 120_000, + ) it(`leaves externally inconsistent metadata reachable for consumer validation`, async () => { const observation = await observeResetResumeHistory( @@ -3539,9 +3541,10 @@ export function runSQLiteCoreAdapterContractSuite( * fails the public result/binding comparison. The legacy CLI test interpolates * literals, so it cannot exercise this boundary. * - * Replay: pass `TANSTACK_DB_SQLITE_BINDING_SEED` and - * `TANSTACK_DB_SQLITE_BINDING_PATH` together and select this test by - * name. The normal package test runs fixed and random campaigns. + * Replay: `TANSTACK_DB_ORACLE_PATH` with `TANSTACK_DB_ORACLE_SEED` and + * `TANSTACK_DB_ORACLE_PROPERTY=sqlite-persistence.binding-capacity` replays the + * recorded random-campaign history. The normal package test runs fixed and + * random campaigns. */ const BINDING_CAP = 999 const BINDING_FIXED_SEED = 1_993_999 @@ -4261,27 +4264,8 @@ async function runCursorContext(): Promise { return problem } -const bindingReplaySeed = process.env.TANSTACK_DB_SQLITE_BINDING_SEED -const bindingReplayPath = process.env.TANSTACK_DB_SQLITE_BINDING_PATH -const bindingReplay = - bindingReplaySeed !== undefined && bindingReplayPath !== undefined -if ((bindingReplaySeed === undefined) !== (bindingReplayPath === undefined)) { - throw new Error(`Binding oracle replay requires both seed and path`) -} -if ( - bindingReplaySeed !== undefined && - !Number.isSafeInteger(Number(bindingReplaySeed)) -) { - throw new Error(`Binding oracle replay seed must be an integer`) -} -if ( - bindingReplayPath !== undefined && - !/^\d+(?::\d+)*$/.test(bindingReplayPath) -) { - throw new Error(`Binding oracle replay path must be numeric`) -} - -const bindingFixedIt = bindingReplay ? it.skip : it +const BINDING_PROPERTY = `sqlite-persistence.binding-capacity` +const bindingFixedIt = oracleReplayTest(it) export function runSQLiteBindingCapacityOracleSuite(): void { describe(`SQLite subset binding-capacity oracle`, () => { @@ -4399,7 +4383,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { async function campaign( label: string, - parameters: { seed?: number; path?: string }, + parameters: { numRuns: number; seed?: number; path?: string }, ) { let firstFailure: Violation | undefined const property = fc.asyncProperty(generated, async (spec) => { @@ -4413,10 +4397,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { ) } }) - const details = await fc.check(property, { - numRuns: BINDING_RUNS, - ...parameters, - }) + const details = await fc.check(property, parameters) const reduced = details.counterexample?.[0] const reducedProblem = reduced ? violation(reduced, await observe(reduced)) @@ -4435,20 +4416,24 @@ export function runSQLiteBindingCapacityOracleSuite(): void { ).toBe(false) } - if (bindingReplay) { - it(`replays the requested binding history directly`, async () => { - await campaign(`direct replay`, { - seed: Number(bindingReplaySeed), - path: bindingReplayPath, + bindingFixedIt( + `preserves exact rows across fixed-seed binding histories`, + async () => { + await campaign(`fixed seed`, { + numRuns: BINDING_RUNS, + seed: BINDING_FIXED_SEED, }) - }) - } else { - it(`preserves exact rows across fixed-seed binding histories`, async () => { - await campaign(`fixed seed`, { seed: BINDING_FIXED_SEED }) - }) - it(`preserves exact rows across random-seed binding histories`, async () => { - await campaign(`random seed`, {}) - }) - } + }, + ) + // Runs in ordinary and seed-only runs, and as the replay of its property. + oracleReplayTest(it, BINDING_PROPERTY)( + `preserves exact rows across random-seed binding histories`, + async () => { + await campaign( + `random seed`, + oraclePropertyOptions(BINDING_RUNS, BINDING_PROPERTY), + ) + }, + ) }) } diff --git a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts index 518e1472aa..9d23d955d3 100644 --- a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts @@ -7,8 +7,9 @@ import { createLiveQueryCollection } from '../src/query/index.js' import { createTransaction } from '../src/transactions.js' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from './oracle-config.js' import { withHistoryCleanup } from './optimistic-history-oracle.js' import type { Collection } from '../src/collection/index.js' @@ -164,7 +165,6 @@ const metadataCancellationArbitrary = fc.record({ const metadataOnlyProperty = `collection-publication.metadata-only` const metadataCancellationProperty = `collection-publication.metadata-cancellation` -const requestedReplayProperty = readOracleRunConfig().replayProperty async function createPublicationHarness(): Promise { let sync!: SyncActions @@ -818,7 +818,7 @@ const runCancellationHistory = ({ ) describe(`generated metadata publication histories`, () => { - if (requestedReplayProperty === undefined) { + if (!oracleReplayRequested()) { fcTest.prop([publicationHistoryArbitrary], { numRuns: oracleRuns(50), seed: 1_805_001, @@ -826,13 +826,6 @@ describe(`generated metadata publication histories`, () => { `keeps metadata-only optimistic settlement a valid keyed diff (fixed)`, runPublicationHistory, ) - fcTest.prop( - [publicationHistoryArbitrary], - oraclePropertyOptions(50, metadataOnlyProperty), - )( - `keeps metadata-only optimistic settlement a valid keyed diff (random)`, - runPublicationHistory, - ) fcTest.prop([metadataCancellationArbitrary], { numRuns: oracleRuns(50), @@ -841,22 +834,21 @@ describe(`generated metadata publication histories`, () => { `keeps metadata suppression owned by pending transactions (fixed)`, runCancellationHistory, ) - fcTest.prop( - [metadataCancellationArbitrary], - oraclePropertyOptions(50, metadataCancellationProperty), - )( - `keeps metadata suppression owned by pending transactions (random)`, - runCancellationHistory, - ) - } else if (requestedReplayProperty === metadataOnlyProperty) { - fcTest.prop( - [publicationHistoryArbitrary], - oraclePropertyOptions(50, metadataOnlyProperty), - )(`replays metadata-only optimistic settlement`, runPublicationHistory) - } else if (requestedReplayProperty === metadataCancellationProperty) { - fcTest.prop( - [metadataCancellationArbitrary], - oraclePropertyOptions(50, metadataCancellationProperty), - )(`replays metadata cancellation ownership`, runCancellationHistory) } + // A path replay of one property runs its one recorded history through that + // property's registration. The other registration stands aside. + oracleReplayTest(fcTest, metadataOnlyProperty).prop( + [publicationHistoryArbitrary], + oraclePropertyOptions(50, metadataOnlyProperty), + )( + `keeps metadata-only optimistic settlement a valid keyed diff (random)`, + runPublicationHistory, + ) + oracleReplayTest(fcTest, metadataCancellationProperty).prop( + [metadataCancellationArbitrary], + oraclePropertyOptions(50, metadataCancellationProperty), + )( + `keeps metadata suppression owned by pending transactions (random)`, + runCancellationHistory, + ) }) diff --git a/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts b/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts index df7a9775d5..2350cbab5a 100644 --- a/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts @@ -23,6 +23,8 @@ import { flushPromises, withOracleCleanup } from './utils.js' import { oraclePropertyOptions, oracleRandomParameters, + oracleReplayRequested, + oracleReplayTest, readOracleRunConfig, } from './oracle-config.js' import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' @@ -37,12 +39,9 @@ import type { } from './collection-subscription-lifecycle-grammar-oracle.js' const { multiplier, ...replay } = readOracleRunConfig() -const it = replay.replayPath === undefined ? vitestIt : vitestIt.skip -const fixedCampaign = replay.replayPath === undefined ? fcTest : fcTest.skip -const randomCampaign = (property: string) => - replay.replayPath === undefined || replay.replayProperty === property - ? fcTest - : fcTest.skip +const it = oracleReplayTest(vitestIt) +const fixedCampaign = oracleReplayTest(fcTest) +const randomCampaign = (property: string) => oracleReplayTest(fcTest, property) /** * # What settles a demand created while a Collection is cleaned up? @@ -853,7 +852,7 @@ async function runHistory( } if ( - replay.replayPath === undefined && + !oracleReplayRequested() && process.env.TANSTACK_DB_ORACLE_STATISTICS === `1` ) { fc.statistics( diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 0a7ae23cca..6c7f9dbe20 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -12,6 +12,8 @@ import { import { oraclePropertyOptions, oracleRandomParameters, + oracleReplayRequested, + oracleReplayTest, readOracleRunConfig, } from './oracle-config.js' import type { CollectionSubscription } from '../src/collection/subscription.js' @@ -1049,7 +1051,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { afterAll(() => { // A direct property replay may select only that test by name. - if (replay.replayPath !== undefined) return + if (oracleReplayRequested()) return expect(observedAcquisitionCells).toEqual(legalAcquisitionCells) expect(observedAcquisitionInteractions).toEqual( requiredAcquisitionInteractions, @@ -4409,7 +4411,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }, ) - const fixedCampaign = replay.replayPath === undefined ? fcTest : fcTest.skip + const fixedCampaign = oracleReplayTest(fcTest) fixedCampaign.prop([asyncRestartScenarioArbitrary], { numRuns: 30 * multiplier, diff --git a/packages/db/tests/comparison-oracle.property.test.ts b/packages/db/tests/comparison-oracle.property.test.ts index 1f8277456f..449563f40a 100644 --- a/packages/db/tests/comparison-oracle.property.test.ts +++ b/packages/db/tests/comparison-oracle.property.test.ts @@ -10,6 +10,12 @@ import { normalizeValue, } from '../src/utils/comparison' import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' +import { + oraclePropertyOptions, + oracleReplayRequested, + oracleReplaySkips, + oracleRuns, +} from './oracle-config.js' import type { CompareOptions } from '../src/query/builder/types' /** @@ -74,9 +80,8 @@ const nullsLastOpts: CompareOptions = { // --configLoader runner tests/comparison-oracle.property.test.ts. const fixedSeed = 20260911 const campaignRuns = 100 -const requestedProperty = process.env.COMPARISON_ORACLE_PROPERTY -const replaySeed = process.env.COMPARISON_ORACLE_SEED -const replayPath = process.env.COMPARISON_ORACLE_PATH +// Each property registers as `comparison.`, in file order. A path +// replay selects one with TANSTACK_DB_ORACLE_PROPERTY=comparison.. let nextPropertyId = 0 type ComparisonFailure = { checkpoint: string; error: unknown } @@ -161,45 +166,27 @@ function comparisonProperty< check: (...values: Ts) => void, ) => void { return (name: string, check: (...values: Ts) => void): void => { - const id = String(++nextPropertyId) + const id = ++nextPropertyId + const property = `comparison.${id}` const label = `${id}: ${name}` const arbitrary = fc.tuple(...arbitraries) const checkValues = (values: Ts) => { check(...values) } - if (requestedProperty !== undefined) { - if (requestedProperty !== id) { - it.skip(`${label} replay not selected`, () => {}) - return - } - if (replaySeed === undefined || replayPath === undefined) { - throw new Error(`comparison oracle replay needs a seed and shrink path`) - } - const seed = Number(replaySeed) - if (replaySeed.trim() === `` || !Number.isSafeInteger(seed)) { - throw new Error(`comparison oracle replay seed must be an integer`) - } - it(`${label} replay`, () => { - assertComparisonProperty(label, arbitrary, checkValues, { - seed, - path: replayPath, - numRuns: campaignRuns, - }) - }) - return - } - - it(`${label} fixed`, () => { + it.skipIf(oracleReplayRequested())(`${label} fixed`, () => { assertComparisonProperty(label, arbitrary, checkValues, { seed: fixedSeed, - numRuns: campaignRuns, + numRuns: oracleRuns(campaignRuns), }) }) - it(`${label} random`, () => { - assertComparisonProperty(label, arbitrary, checkValues, { - numRuns: campaignRuns, - }) + it.skipIf(oracleReplaySkips(property))(`${label} random`, () => { + assertComparisonProperty( + label, + arbitrary, + checkValues, + oraclePropertyOptions(campaignRuns, property), + ) }) } } @@ -1302,11 +1289,3 @@ describe(`Temporal default-order refinement`, () => { expect(() => assertZonedDateTimeOrder(sample, truncated)).toThrow() }) }) - -if (requestedProperty !== undefined) { - it(`selects a registered comparison oracle property`, () => { - expect(requestedProperty).toMatch(/^\d+$/) - expect(Number(requestedProperty)).toBeGreaterThanOrEqual(1) - expect(Number(requestedProperty)).toBeLessThanOrEqual(nextPropertyId) - }) -} diff --git a/packages/db/tests/cursor-oracle.property.test.ts b/packages/db/tests/cursor-oracle.property.test.ts index 410107caaa..25c74e4742 100644 --- a/packages/db/tests/cursor-oracle.property.test.ts +++ b/packages/db/tests/cursor-oracle.property.test.ts @@ -5,8 +5,8 @@ import { PropRef, Value } from '../src/query/ir.js' import { buildCursor } from '../src/utils/cursor.js' import { oraclePropertyOptions, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from './oracle-config.js' import { withHistoryCleanup } from './optimistic-history-oracle.js' import { evaluateReferenceExpression } from './reference-expression-oracle.js' @@ -31,13 +31,9 @@ import type { OrderBy } from '../src/query/ir.js' * paginated query's acquisition and publication behavior. */ -const replay = readOracleRunConfig() -const it = replay.replayPath === undefined ? vitestIt : vitestIt.skip -const fixedCampaign = replay.replayPath === undefined ? fcTest : fcTest.skip -const randomCampaign = (property: string) => - replay.replayPath === undefined || replay.replayProperty === property - ? fcTest - : fcTest.skip +const it = oracleReplayTest(vitestIt) +const fixedCampaign = oracleReplayTest(fcTest) +const randomCampaign = (property: string) => oracleReplayTest(fcTest, property) const fixedSeed = 20260930 diff --git a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts index a2f6480b0a..85ae9bd339 100644 --- a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts +++ b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts @@ -10,17 +10,15 @@ import { createEffect } from '../src/query/effect.js' import { reconcileChangesForD2 } from '../src/query/live/utils.js' import { oraclePropertyOptions, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from './oracle-config.js' import { flushPromises } from './utils.js' import type { ChangeMessage, SyncConfig } from '../src/types.js' -const { replayPath, replayProperty } = readOracleRunConfig() -const it = replayPath === undefined ? vitestIt : vitestIt.skip -const fixedCampaign = replayPath === undefined ? fcTest : fcTest.skip -const randomCampaign = (property: string) => - replayPath === undefined || replayProperty === property ? fcTest : fcTest.skip +const it = oracleReplayTest(vitestIt) +const fixedCampaign = oracleReplayTest(fcTest) +const randomCampaign = (property: string) => oracleReplayTest(fcTest, property) /** * D2 source reconciliation turns source-key snapshots into exact signed row diff --git a/packages/db/tests/flat-change-tracking-oracle.property.test.ts b/packages/db/tests/flat-change-tracking-oracle.property.test.ts index 634beafd2d..150f6c98c8 100644 --- a/packages/db/tests/flat-change-tracking-oracle.property.test.ts +++ b/packages/db/tests/flat-change-tracking-oracle.property.test.ts @@ -79,12 +79,12 @@ import { } from '../src/proxy.js' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from './oracle-config.js' const property = `flat-change-tracking.equivalence` -const requestedReplayProperty = readOracleRunConfig().replayProperty const MISSING = Symbol(`missing`) const FRESH_OBJECT = Symbol(`fresh object`) @@ -660,7 +660,7 @@ function runHistory(history: History): void { } describe(`flat change tracking oracle`, () => { - if (requestedReplayProperty === undefined) { + if (!oracleReplayRequested()) { for (const { name, history } of pinnedHistories) { it(`matches the draft proxy when ${name}`, () => runHistory(history)) } @@ -713,16 +713,10 @@ describe(`flat change tracking oracle`, () => { seed: 44_502_101, numRuns: oracleRuns(200), })(`matches the draft proxy (fixed)`, runHistory) - fcTest.prop([historyArbitrary], oraclePropertyOptions(200, property))( - `matches the draft proxy (random)`, - runHistory, - ) - } else if (requestedReplayProperty === property) { - fcTest.prop([historyArbitrary], oraclePropertyOptions(200, property))( - `matches the draft proxy (replay)`, - runHistory, - ) - } else { - it.skip(`runs only when its replay property is selected`, () => {}) } + // A path replay of this property runs its one recorded history here. + oracleReplayTest(fcTest, property).prop( + [historyArbitrary], + oraclePropertyOptions(200, property), + )(`matches the draft proxy (random)`, runHistory) }) diff --git a/packages/db/tests/indexed-db/ORACLE.md b/packages/db/tests/indexed-db/ORACLE.md index 439c095e83..72befcd217 100644 --- a/packages/db/tests/indexed-db/ORACLE.md +++ b/packages/db/tests/indexed-db/ORACLE.md @@ -360,8 +360,8 @@ pnpm test:indexed-db:oracles TANSTACK_INDEXEDDB_ORACLE_PROFILE=stress pnpm test:indexed-db:oracles pnpm exec playwright install chromium firefox webkit pnpm test:indexed-db:browser -# Direct selected history; fixed/fresh campaigns do not run first: -TANSTACK_INDEXEDDB_ORACLE_SEED= TANSTACK_INDEXEDDB_ORACLE_PATH= pnpm test:indexed-db:oracles +# Direct selected cross-tab history; the fixed campaign does not run first: +TANSTACK_DB_ORACLE_SEED= TANSTACK_DB_ORACLE_PATH= TANSTACK_DB_ORACLE_PROPERTY=indexeddb.cross-tab-history pnpm test:indexed-db:oracles TANSTACK_INDEXEDDB_PENDING_SEED= TANSTACK_INDEXEDDB_PENDING_PATH= pnpm test:indexed-db:oracles TANSTACK_INDEXEDDB_BROWSER_SEED= TANSTACK_INDEXEDDB_BROWSER_PATH= pnpm test:indexed-db:browser --project=chromium ``` diff --git a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts index cfd33f97ba..12e09ac5d9 100644 --- a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts @@ -18,6 +18,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fc } from '@fast-check/vitest' import { expect, it, vi } from 'vitest' +import { + oraclePropertyOptions, + oracleReplayRequested, + oracleReplaySkips, +} from '../oracle-config.js' import { runCampaign } from './campaign' import { runTransportHistory } from './cross-tab-driver' import { @@ -76,26 +81,23 @@ async function campaign(seed?: number, path?: string) { ) } -if (process.env.TANSTACK_INDEXEDDB_ORACLE_SEED) { - const seed = Number(process.env.TANSTACK_INDEXEDDB_ORACLE_SEED) - if (!Number.isInteger(seed)) throw new Error('Invalid oracle replay seed') - it( - 'replays the selected cross-tab history', - () => campaign(seed, process.env.TANSTACK_INDEXEDDB_ORACLE_PATH), - 120_000, - ) -} else { - it( - 'refines independent per-Collection snapshots with fixed histories', - () => campaign(11792026), - 120_000, - ) - it( - 'refines independent per-Collection snapshots with fresh histories', - () => campaign(), - 120_000, - ) -} +// A path replay of this property runs only the fresh campaign, from the +// recorded seed and path. A seed without a path reseeds the fresh campaign. +const crossTabProperty = `indexeddb.cross-tab-history` + +it.skipIf(oracleReplayRequested())( + 'refines independent per-Collection snapshots with fixed histories', + () => campaign(11792026), + 120_000, +) +it.skipIf(oracleReplaySkips(crossTabProperty))( + 'refines independent per-Collection snapshots with fresh histories', + () => { + const { seed, path } = oraclePropertyOptions(runs, crossTabProperty) + return campaign(seed, path) + }, + 120_000, +) it('reconstructs every action and overlapping receiver reads', async () => { const reached = await runTransportHistory([ diff --git a/packages/db/tests/indexed-db/persistence-oracle.test.ts b/packages/db/tests/indexed-db/persistence-oracle.test.ts index 9b59fe8c47..03104aacdc 100644 --- a/packages/db/tests/indexed-db/persistence-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-oracle.test.ts @@ -23,6 +23,7 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { createTransaction } from '../../src' +import { oracleRuns } from '../oracle-config' import { Channel, assertRows, @@ -322,11 +323,11 @@ describe('settled histories', () => { 'matches authored data through ' + mode + ' histories', async () => { console.info('indexeddb.persistence campaign', mode, { - numRuns: 30, + numRuns: oracleRuns(30), ...parameters, }) await fc.assert(fc.asyncProperty(history, runHistory), { - numRuns: 30, + numRuns: oracleRuns(30), verbose: true, ...parameters, asyncReporter: async (details) => { diff --git a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts index 90c78df154..c1db194015 100644 --- a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts +++ b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts @@ -34,8 +34,9 @@ import { localStorageCollectionOptions } from '../src/local-storage' import { createTransaction } from '../src/transactions' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from './oracle-config.js' import { observeHistoryPromise, @@ -43,7 +44,6 @@ import { } from './optimistic-history-oracle' const property = `local-storage.value-fidelity` -const requestedReplayProperty = readOracleRunConfig().replayProperty // --------------------------------------------------------------------------- // Model. Each generated value carries the JSON form a restore must return, or @@ -373,7 +373,7 @@ async function runHistory({ entry, handler, payload }: History) { } describe(`LocalStorage value fidelity oracle`, () => { - if (requestedReplayProperty === undefined) { + if (!oracleReplayRequested()) { // Pinned witnesses. The first is the reported case: a function property in // an inserted row. The others place each lost kind under an array or an // object, and check each documented normalization on every entry path. @@ -415,8 +415,9 @@ describe(`LocalStorage value fidelity oracle`, () => { ] as const for (const entry of entries) { for (const handler of [true, false]) { - for (const payload of pinned) { - if (!isLegal({ entry, handler, payload })) continue + for (const payload of pinned.filter((candidate) => + isLegal({ entry, handler, payload: candidate }), + )) { it(`${entry} ${handler ? `with` : `without`} a handler of ${payload.label} ${payload.restored === LOST ? `rejects` : `persists its JSON form`}`, () => runHistory({ entry, handler, payload })) } @@ -441,16 +442,10 @@ describe(`LocalStorage value fidelity oracle`, () => { seed: 20_261_021, numRuns: oracleRuns(300), })(`matches the JSON value model (fixed)`, runHistory) - fcTest.prop([historyArbitrary], oraclePropertyOptions(300, property))( - `matches the JSON value model (random)`, - runHistory, - ) - } else if (requestedReplayProperty === property) { - fcTest.prop([historyArbitrary], oraclePropertyOptions(300, property))( - `matches the JSON value model (replay)`, - runHistory, - ) - } else { - it.skip(`runs only when its replay property is selected`, () => {}) } + // A path replay of this property runs its one recorded history here. + oracleReplayTest(fcTest, property).prop( + [historyArbitrary], + oraclePropertyOptions(300, property), + )(`matches the JSON value model (random)`, runHistory) }) diff --git a/packages/db/tests/notification-laws-oracle.test.ts b/packages/db/tests/notification-laws-oracle.test.ts index 606fc8240f..f09ec97a4d 100644 --- a/packages/db/tests/notification-laws-oracle.test.ts +++ b/packages/db/tests/notification-laws-oracle.test.ts @@ -23,6 +23,7 @@ import { expectNotificationsStopped, } from '../../db-collection-e2e/src/utils/notification-laws-oracle' import { ScenarioLifetime } from './conformance/scenario-lifetime' +import { oracleRuns } from './oracle-config' import type { Notification } from '../../db-collection-e2e/src/utils/notification-laws-oracle' type Row = { id: string; count: number } @@ -110,7 +111,7 @@ it.each([20260913, undefined])( ), { seed, - numRuns: 30, + numRuns: oracleRuns(30), examples: [ [1, [2, null]], [1, [null]], @@ -176,7 +177,7 @@ it.each([20260913, undefined])( ).toThrow() }, ), - { seed, numRuns: 100 }, + { seed, numRuns: oracleRuns(100) }, ) }, ) @@ -250,7 +251,7 @@ it.each([20260913, undefined])( expectNotificationHistory([], [...callbacks, callbacks[1]!], [next]), ).toThrow() }), - { seed, numRuns: 50 }, + { seed, numRuns: oracleRuns(50) }, ) }, ) diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 7bcf5f9337..3139a9ad76 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -18,7 +18,11 @@ const ownerGroups: ReadonlyArray = [ `sorted-map`, `key ascending descending`, ], - [`db/tests/oracle-replay.fixture.test.ts`, `oracle-replay`, `calibration`], + [ + `db/tests/replay-guard-calibration.fixture.test.ts`, + `oracle-replay`, + `calibration`, + ], [ `db-ivm/tests/structural-identity-oracle.property.test.ts`, `structural-identity`, diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index 05da1ba869..6417c15f6f 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -33,7 +33,7 @@ import { const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), `..`) const repositoryDirectory = resolve(packageDirectory, `../..`) -const fixture = `tests/oracle-replay.fixture.test.ts` +const fixture = `tests/replay-guard-calibration.fixture.test.ts` function runReplay( args: Array = [fixture], diff --git a/packages/db/tests/proxy-detachment-contract-oracle.test.ts b/packages/db/tests/proxy-detachment-contract-oracle.test.ts index 9f4ea51bfe..31a5251e50 100644 --- a/packages/db/tests/proxy-detachment-contract-oracle.test.ts +++ b/packages/db/tests/proxy-detachment-contract-oracle.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import fc from 'fast-check' import { createCollection } from '../src/collection/index.js' import { createChangeProxy, withChangeTracking } from '../src/proxy.js' +import { oracleRuns } from './oracle-config.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { CollectionConfig } from '../src/types.js' @@ -505,7 +506,11 @@ describe(`Mutation result detachment`, () => { expect(original).toEqual({ payload: { before: true } }) }, ), - { seed, numRuns: 100, examples: [[`__proto__`, 1, false]] }, + { + seed, + numRuns: oracleRuns(100), + examples: [[`__proto__`, 1, false]], + }, ) }, ) diff --git a/packages/db/tests/proxy-oracle.test.ts b/packages/db/tests/proxy-oracle.test.ts index a6135a77dc..893adab42e 100644 --- a/packages/db/tests/proxy-oracle.test.ts +++ b/packages/db/tests/proxy-oracle.test.ts @@ -8,6 +8,7 @@ import { withArrayChangeTracking, withChangeTracking, } from '../src/proxy' +import { oracleRuns } from './oracle-config' /** * # Does a draft behave like the native value it represents? @@ -282,7 +283,7 @@ describe(`native array callback oracle`, () => { assertArrayCallbacks(observeArrayCallbacks(values, steps)) }), { - numRuns: 200, + numRuns: oracleRuns(200), ...(seed === undefined ? {} : { seed }), ...(replayPath === undefined ? {} : { path: replayPath }), }, diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 7edad41010..a2bd6133f6 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { createChangeProxy, withChangeTracking } from '../src/proxy' +import { oracleRuns } from './oracle-config' /** * # Which draft writes count as changes, and which count as reverts? @@ -812,28 +813,28 @@ describe(`draft revert oracle`, () => { ) throw new Error(`TANSTACK_DB_PROXY_REVERT_SEED must be an integer`) fc.assert(fc.property(historyArb, expectHistory), { - numRuns: 400, + numRuns: oracleRuns(400), ...(seed === undefined ? {} : { seed }), ...(replayPath === undefined ? {} : { path: replayPath }), }) }) it(`matches the model across generated nested round trips (${name})`, () => { fc.assert(fc.property(nestedRoundTripArb, expectHistory), { - numRuns: 200, + numRuns: oracleRuns(200), ...(seed === undefined ? {} : { seed }), ...(replayPath === undefined ? {} : { path: replayPath }), }) }) it(`matches the model across generated URL and keyless writes (${name})`, () => { fc.assert(fc.property(classWriteArb, expectHistory), { - numRuns: 200, + numRuns: oracleRuns(200), ...(seed === undefined ? {} : { seed }), ...(replayPath === undefined ? {} : { path: replayPath }), }) }) it(`matches the model across generated partial reverts (${name})`, () => { fc.assert(fc.property(partialRevertArb, expectHistory), { - numRuns: 200, + numRuns: oracleRuns(200), ...(seed === undefined ? {} : { seed }), ...(replayPath === undefined ? {} : { path: replayPath }), }) @@ -1429,7 +1430,7 @@ describe(`draft revert oracle: native mutators mixed with assignments`, () => { for (const { name, seed } of campaigns) { it(`matches the model across generated native histories (${name})`, () => { fc.assert(fc.property(nativeHistoryArb, expectNativeHistory), { - numRuns: 400, + numRuns: oracleRuns(400), ...(seed === undefined ? {} : { seed }), }) }) diff --git a/packages/db/tests/query/includes-collection-oracle.property.test.ts b/packages/db/tests/query/includes-collection-oracle.property.test.ts index b162ba7e5e..fd3b643982 100644 --- a/packages/db/tests/query/includes-collection-oracle.property.test.ts +++ b/packages/db/tests/query/includes-collection-oracle.property.test.ts @@ -20,8 +20,8 @@ import { runTrace } from '../trace-runner.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { oraclePropertyOptions, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../oracle-config.js' import { flushPromises, withExpectedRejection } from '../utils.js' import { createControlledCollection as createOracleControlledCollection } from './includes-oracle-helpers.js' @@ -675,7 +675,6 @@ const exhaustiveActions: ReadonlyArray = [ { type: `deleteChild`, id: 10 }, ] -const requestedReplayProperty = readOracleRunConfig().replayProperty const relationshipProperty = `includes-collection.relationship-history` const publicKeyOrderProperty = `includes-collection.public-key-order` const layoutSwapProperty = `includes-collection.layout-swap` @@ -822,27 +821,20 @@ describe(`Collection-valued includes oracle`, () => { projection: collectionProjection, }) - if (requestedReplayProperty === undefined) { - fcTest.prop([collectionScenarioArbitrary], { - numRuns: oracleRuns(30), - seed: 1_658_101, - })( - `keeps Collection, toArray, and materialize equivalent across fixed relationship histories`, - runRelationshipHistory, - ) - } - if ( - requestedReplayProperty === undefined || - requestedReplayProperty === relationshipProperty - ) { - fcTest.prop( - [collectionScenarioArbitrary], - oraclePropertyOptions(30, relationshipProperty), - )( - `keeps Collection, toArray, and materialize equivalent across random or replayed relationship histories`, - runRelationshipHistory, - ) - } + oracleReplayTest(fcTest).prop([collectionScenarioArbitrary], { + numRuns: oracleRuns(30), + seed: 1_658_101, + })( + `keeps Collection, toArray, and materialize equivalent across fixed relationship histories`, + runRelationshipHistory, + ) + oracleReplayTest(fcTest, relationshipProperty).prop( + [collectionScenarioArbitrary], + oraclePropertyOptions(30, relationshipProperty), + )( + `keeps Collection, toArray, and materialize equivalent across random or replayed relationship histories`, + runRelationshipHistory, + ) fcTest( `exhaustively matches every two-step history in the smallest relationship domain`, @@ -2078,27 +2070,20 @@ describe(`Collection-valued includes oracle`, () => { ) } - if (requestedReplayProperty === undefined) { - fcTest.prop([publicKeyOrderArbitrary], { - numRuns: oracleRuns(20), - seed: 1_658_102, - })( - `uses one raw public-key order across Collection and inline materializations with a fixed seed`, - runPublicKeyOrder, - ) - } - if ( - requestedReplayProperty === undefined || - requestedReplayProperty === publicKeyOrderProperty - ) { - fcTest.prop( - [publicKeyOrderArbitrary], - oraclePropertyOptions(20, publicKeyOrderProperty), - )( - `uses one raw public-key order across Collection and inline materializations with a random or replayed seed`, - runPublicKeyOrder, - ) - } + oracleReplayTest(fcTest).prop([publicKeyOrderArbitrary], { + numRuns: oracleRuns(20), + seed: 1_658_102, + })( + `uses one raw public-key order across Collection and inline materializations with a fixed seed`, + runPublicKeyOrder, + ) + oracleReplayTest(fcTest, publicKeyOrderProperty).prop( + [publicKeyOrderArbitrary], + oraclePropertyOptions(20, publicKeyOrderProperty), + )( + `uses one raw public-key order across Collection and inline materializations with a random or replayed seed`, + runPublicKeyOrder, + ) const runLayoutSwap = async ({ length, @@ -2213,27 +2198,20 @@ describe(`Collection-valued includes oracle`, () => { ) } - if (requestedReplayProperty === undefined) { - fcTest.prop([orderSwapArbitrary], { - numRuns: oracleRuns(20), - seed: 1_658_103, - })( - `propagates order-only child swaps through every materialization with a fixed seed`, - runLayoutSwap, - ) - } - if ( - requestedReplayProperty === undefined || - requestedReplayProperty === layoutSwapProperty - ) { - fcTest.prop( - [orderSwapArbitrary], - oraclePropertyOptions(20, layoutSwapProperty), - )( - `propagates order-only child swaps through every materialization with a random or replayed seed`, - runLayoutSwap, - ) - } + oracleReplayTest(fcTest).prop([orderSwapArbitrary], { + numRuns: oracleRuns(20), + seed: 1_658_103, + })( + `propagates order-only child swaps through every materialization with a fixed seed`, + runLayoutSwap, + ) + oracleReplayTest(fcTest, layoutSwapProperty).prop( + [orderSwapArbitrary], + oraclePropertyOptions(20, layoutSwapProperty), + )( + `propagates order-only child swaps through every materialization with a random or replayed seed`, + runLayoutSwap, + ) for (const { intermediate, final } of [ { intermediate: `A`, final: `A` }, @@ -3218,25 +3196,18 @@ describe(`Collection-valued includes oracle`, () => { }, ) - if (requestedReplayProperty === undefined) { - fcTest.prop([optimisticChildArbitrary], { - numRuns: oracleRuns(20), - seed: 1_658_104, - })( - `matches optimistic child settlement histories with a fixed seed`, - runOptimisticChildHistory, - ) - } - if ( - requestedReplayProperty === undefined || - requestedReplayProperty === optimisticChildProperty - ) { - fcTest.prop( - [optimisticChildArbitrary], - oraclePropertyOptions(20, optimisticChildProperty), - )( - `matches optimistic child settlement histories with a random or replayed seed`, - runOptimisticChildHistory, - ) - } + oracleReplayTest(fcTest).prop([optimisticChildArbitrary], { + numRuns: oracleRuns(20), + seed: 1_658_104, + })( + `matches optimistic child settlement histories with a fixed seed`, + runOptimisticChildHistory, + ) + oracleReplayTest(fcTest, optimisticChildProperty).prop( + [optimisticChildArbitrary], + oraclePropertyOptions(20, optimisticChildProperty), + )( + `matches optimistic child settlement histories with a random or replayed seed`, + runOptimisticChildHistory, + ) }) diff --git a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts index 46523e423b..53e38f0811 100644 --- a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts +++ b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts @@ -17,6 +17,8 @@ import { } from '../../src/query/index.js' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, readOracleRunConfig, } from '../oracle-config.js' @@ -983,17 +985,14 @@ const windowedExamples: Array< describe(`includes cross-formulation oracle`, () => { const replay = readOracleRunConfig() - const fixedCampaign = replay.replayPath === undefined ? fcTest : fcTest.skip - const randomCampaign = (property: string) => - replay.replayPath === undefined || replay.replayProperty === property - ? fcTest - : fcTest.skip - const calibrationTest = (property: string, fault: string) => - replay.replayPath === undefined || - (replay.replayProperty === property && - process.env.TANSTACK_DB_ORACLE_CALIBRATION === fault) - ? test - : test.skip + // A calibration witness injects a hostile mutant into its main property + // during a replay of that property, selected by + // TANSTACK_DB_ORACLE_CALIBRATION. It runs in ordinary runs and in replays of + // its property. A replay of the property without its fault replays the + // main property, so the witness has nothing to check then. + const calibrationIdle = (fault: string) => + oracleReplayRequested() && + process.env.TANSTACK_DB_ORACLE_CALIBRATION !== fault test(`bounded grammar axes distinguish routes, order, partitions, and windows`, () => { const parents = new Map( @@ -1523,7 +1522,7 @@ describe(`includes cross-formulation oracle`, () => { // The literal wrong-result and captured replay controls above distinguish // merged reference routes and symbol groups at their public checkpoints. - fixedCampaign.prop([fc.integer()], { + oracleReplayTest(fcTest).prop([fc.integer()], { numRuns: oracleRuns(4), seed: 1_658_001, })( @@ -1533,7 +1532,7 @@ describe(`includes cross-formulation oracle`, () => { }, ) - randomCampaign(`includes-cross-formulation.reference-context`).prop( + oracleReplayTest(fcTest, `includes-cross-formulation.reference-context`).prop( [fc.integer()], oraclePropertyOptions(4, `includes-cross-formulation.reference-context`), )( @@ -1828,7 +1827,7 @@ describe(`includes cross-formulation oracle`, () => { }, ) - fixedCampaign.prop([fc.integer()], { + oracleReplayTest(fcTest).prop([fc.integer()], { numRuns: oracleRuns(4), seed: 1_658_002, })( @@ -1836,7 +1835,7 @@ describe(`includes cross-formulation oracle`, () => { checkReferenceKeyRouting, ) - randomCampaign(`includes-cross-formulation.reference-key`).prop( + oracleReplayTest(fcTest, `includes-cross-formulation.reference-key`).prop( [fc.integer()], oraclePropertyOptions(4, `includes-cross-formulation.reference-key`), )( @@ -1989,7 +1988,7 @@ describe(`includes cross-formulation oracle`, () => { ]) } - fixedCampaign.prop([fc.integer()], { + oracleReplayTest(fcTest).prop([fc.integer()], { numRuns: oracleRuns(4), seed: 1_658_003, })( @@ -1997,7 +1996,10 @@ describe(`includes cross-formulation oracle`, () => { checkSymbolGroupRouting, ) - randomCampaign(`includes-cross-formulation.symbol-group-route`).prop( + oracleReplayTest( + fcTest, + `includes-cross-formulation.symbol-group-route`, + ).prop( [fc.integer()], oraclePropertyOptions(4, `includes-cross-formulation.symbol-group-route`), )( @@ -2171,9 +2173,10 @@ describe(`includes cross-formulation oracle`, () => { // tests/query/includes-cross-formulation-oracle.property.test.ts // -t 'agrees across nested includes' // Expected: one main-property failure at cross-formulation checkpoint 0. - calibrationTest(`includes-cross-formulation.equivalence`, `missing-parent`)( + oracleReplayTest(test, `includes-cross-formulation.equivalence`)( `generated equivalence rejects a missing production parent and replays its checkpoint`, async () => { + if (calibrationIdle(`missing-parent`)) return // The hostile observation is injected after the real nested query is // captured. The same generated driver and refinement comparison execute // during shrinking and direct seed/path replay. @@ -2181,7 +2184,7 @@ describe(`includes cross-formulation oracle`, () => { await expectFormulationsEquivalent(scenario, (rows) => rows.slice(1)) }) const propertyId = `includes-cross-formulation.equivalence` - const directReplay = replay.replayPath !== undefined + const directReplay = oracleReplayRequested() const failure = await fc.check( property, directReplay @@ -2222,9 +2225,10 @@ describe(`includes cross-formulation oracle`, () => { // tests/query/includes-cross-formulation-oracle.property.test.ts // -t 'matches recomputation for ordered offset and limit child windows for a random' // Expected: one main-property failure at ordered-window checkpoint 0. - calibrationTest(`includes-cross-formulation.ordered-window`, `extra-child`)( + oracleReplayTest(test, `includes-cross-formulation.ordered-window`)( `generated window rejects an extra production child and replays its checkpoint`, async () => { + if (calibrationIdle(`extra-child`)) return const property = fc.asyncProperty( windowedScenarioArbitrary, async (sample) => { @@ -2242,7 +2246,7 @@ describe(`includes cross-formulation oracle`, () => { }, ) const propertyId = `includes-cross-formulation.ordered-window` - const directReplay = replay.replayPath !== undefined + const directReplay = oracleReplayRequested() const failure = await fc.check( property, directReplay @@ -2286,7 +2290,7 @@ describe(`includes cross-formulation oracle`, () => { : undefined, ) - fixedCampaign.prop([scenarioArbitrary], { + oracleReplayTest(fcTest).prop([scenarioArbitrary], { numRuns: oracleRuns(8), seed: 1_658_004, examples: scenarioExamples, @@ -2295,11 +2299,11 @@ describe(`includes cross-formulation oracle`, () => { checkEquivalenceScenario, ) - randomCampaign(`includes-cross-formulation.equivalence`).prop( + oracleReplayTest(fcTest, `includes-cross-formulation.equivalence`).prop( [scenarioArbitrary], { ...oraclePropertyOptions(8, `includes-cross-formulation.equivalence`), - examples: replay.replayPath === undefined ? scenarioExamples : [], + examples: oracleReplayRequested() ? [] : scenarioExamples, }, )( `agrees across nested includes, flat joins, per-parent queries, and TLP partitions for a random or replayed seed`, @@ -2334,7 +2338,7 @@ describe(`includes cross-formulation oracle`, () => { : undefined, ) - fixedCampaign.prop([windowedScenarioArbitrary], { + oracleReplayTest(fcTest).prop([windowedScenarioArbitrary], { numRuns: oracleRuns(12), seed: 1_658_005, examples: windowedExamples, @@ -2343,11 +2347,11 @@ describe(`includes cross-formulation oracle`, () => { checkWindowedScenario, ) - randomCampaign(`includes-cross-formulation.ordered-window`).prop( + oracleReplayTest(fcTest, `includes-cross-formulation.ordered-window`).prop( [windowedScenarioArbitrary], { ...oraclePropertyOptions(12, `includes-cross-formulation.ordered-window`), - examples: replay.replayPath === undefined ? windowedExamples : [], + examples: oracleReplayRequested() ? [] : windowedExamples, }, )( `matches recomputation for ordered offset and limit child windows for a random or replayed seed`, diff --git a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts index 1a2013145d..3e7d978e19 100644 --- a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts +++ b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts @@ -14,7 +14,11 @@ import { import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { flushPromises, withExpectedRejection } from '../utils.js' import { runTrace } from '../trace-runner.js' -import { oraclePropertyOptions, readOracleRunConfig } from '../oracle-config.js' +import { + oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, +} from '../oracle-config.js' import { createControlledCollection as createOracleControlledCollection } from './includes-oracle-helpers.js' import type { TraceDriver, TraceProjection } from '../trace-runner.js' import type { OracleSyncChange as SyncChange } from './includes-oracle-helpers.js' @@ -646,14 +650,14 @@ function optimisticProperty( name: string, run: (routes: RouteValues) => Promise, ): void { - const { replayPath, replayProperty } = readOracleRunConfig() - if (replayPath !== undefined && replayProperty !== property) return - const options = oraclePropertyOptions(12, property) // Both normal campaigns use the same arbitrary, driver, checker, and budget. - // A seed-and-path replay registers only its requested property and lane. - if (replayPath !== undefined) { - fcTest.prop([routeValuesArbitrary], options)(name, run) + // A path replay registers only its requested property, as its one lane. + if (oracleReplayRequested()) { + oracleReplayTest(fcTest, property).prop([routeValuesArbitrary], options)( + name, + run, + ) return } diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index d9da48f4ab..c47cfa5d91 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -9,7 +9,12 @@ import { toArray, } from '../../src/query/index.js' import { flushPromises, withExpectedRejection } from '../utils.js' -import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { + oraclePropertyOptions, + oracleReplayRequested, + oracleReplaySkips, + oracleRuns, +} from '../oracle-config.js' import { runTrace } from '../trace-runner.js' import { createControlledCollection as createOracleControlledCollection } from './includes-oracle-helpers.js' import { @@ -214,11 +219,10 @@ const depthArbitrary = fc.constantFrom(1, 2, 3, 4) // a fresh seed at the same budget. A seed-and-path replay runs only its named // property; other matrix cells cannot delay or obscure that reproduction. function generatedCampaigns(runs: number, property: string, fixedSeed: number) { - const replayProperty = process.env.TANSTACK_DB_ORACLE_PROPERTY - if (process.env.TANSTACK_DB_ORACLE_PATH !== undefined) { - return replayProperty === property - ? [{ label: `replay`, options: oraclePropertyOptions(runs, property) }] - : [] + if (oracleReplayRequested()) { + return oracleReplaySkips(property) + ? [] + : [{ label: `replay`, options: oraclePropertyOptions(runs, property) }] } return [ @@ -4978,15 +4982,29 @@ describe(`includes recompute oracle`, () => { `reparent`, `rekey`, ] - for (const [firstIndex, firstTransition] of transitions.entries()) { - for (const [secondIndex, secondTransition] of transitions.entries()) { - if ( + // A transition pair with no history placement at this depth has no + // history to generate, so the matrix registers no cell for it. + const transitionPairs = transitions + .flatMap((firstTransition, firstIndex) => + transitions.map((secondTransition, secondIndex) => ({ + firstIndex, + firstTransition, + secondIndex, + secondTransition, + })), + ) + .filter( + ({ firstTransition, secondTransition }) => transitionHistoryPlacements(depth, firstTransition, secondTransition) - .length === 0 - ) { - continue - } - + .length > 0, + ) + for (const { + firstIndex, + firstTransition, + secondIndex, + secondTransition, + } of transitionPairs) { + { for (const sourceBranch of [0, 1] as const) { const property = `includes.matrix.transition-${depth}-${firstTransition}-${secondTransition}-${sourceBranch}` const arbitrary = transitionHistoryScenariosArbitrary( diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index f146360acd..5e7b182af1 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -19,8 +19,8 @@ import { import { runTrace } from '../trace-runner.js' import { oraclePropertyOptions, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../oracle-config.js' import { captureMicrotaskErrors, flushPromises } from '../utils.js' import type { Collection } from '../../src/collection/index.js' @@ -3343,19 +3343,15 @@ async function expectLaggingSourceTraceMatches( // Each generated lane uses one grammar and one check in both campaigns. A // seed-and-path replay selects only its requested random lane. function temporalCampaigns(property: string, fixedSeed: number) { - const replay = readOracleRunConfig() return [ { label: `fixed seed ${fixedSeed}`, - test: replay.replayPath === undefined ? fcTest : fcTest.skip, + test: oracleReplayTest(fcTest), options: { numRuns: oracleRuns(20), seed: fixedSeed }, }, { label: `random or replayed seed`, - test: - replay.replayPath === undefined || replay.replayProperty === property - ? fcTest - : fcTest.skip, + test: oracleReplayTest(fcTest, property), options: oraclePropertyOptions(20, property), }, ] diff --git a/packages/db/tests/query/ir-stable-identity-oracle.test.ts b/packages/db/tests/query/ir-stable-identity-oracle.test.ts index f475760351..297bc50f60 100644 --- a/packages/db/tests/query/ir-stable-identity-oracle.test.ts +++ b/packages/db/tests/query/ir-stable-identity-oracle.test.ts @@ -53,6 +53,7 @@ import { getRuntimeReferenceIdentity, } from '../../src/query/runtime-reference-identity.js' import { createValueIdentity } from '../../src/query/equality-value-identity.js' +import { oracleRuns } from '../oracle-config.js' import type { BasicExpression, OrderBy, QueryIR } from '../../src/query/ir.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -1539,7 +1540,7 @@ describe(`stable QueryIR identity smoke test`, () => { `matches output denotation through composed contexts with a fixed seed`, (program) => assertOutputProgram(observeOutputProgram(program)), ) - fcTest.prop([outputProgramArbitrary], { numRuns: 200 })( + fcTest.prop([outputProgramArbitrary], { numRuns: oracleRuns(200) })( `matches output denotation through random composed contexts`, (program) => assertOutputProgram(observeOutputProgram(program)), ) diff --git a/packages/db/tests/query/join-result-key-oracle.property.test.ts b/packages/db/tests/query/join-result-key-oracle.property.test.ts index 7fcfafc4e3..faec3dd771 100644 --- a/packages/db/tests/query/join-result-key-oracle.property.test.ts +++ b/packages/db/tests/query/join-result-key-oracle.property.test.ts @@ -46,13 +46,13 @@ import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection, eq } from '../../src/query/index.js' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../oracle-config.js' import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' const property = `join-result-key.pairs` -const requestedReplayProperty = readOracleRunConfig().replayProperty type Key = string | number type Row = { id: Key; g: number } @@ -260,7 +260,10 @@ async function runHistory(history: History): Promise { } describe(`joined result key oracle`, () => { - if (requestedReplayProperty === undefined) { + // The pinned histories and the fixed campaign stand aside during any path + // replay. The random campaign below is the one that a replay of this + // property selects. + if (!oracleReplayRequested()) { for (const history of pinnedHistories) { it(`keeps every ${history.join} join pair for ${history.left .map((row) => @@ -273,17 +276,10 @@ describe(`joined result key oracle`, () => { seed: 44_501_962, numRuns: oracleRuns(80), })(`publishes one row per joined pair (fixed)`, runHistory) - - fcTest.prop([historyArbitrary], oraclePropertyOptions(80, property))( - `publishes one row per joined pair (random)`, - runHistory, - ) - } else if (requestedReplayProperty === property) { - fcTest.prop([historyArbitrary], oraclePropertyOptions(80, property))( - `publishes one row per joined pair (replay)`, - runHistory, - ) - } else { - it.skip(`runs only when its replay property is selected`, () => {}) } + + oracleReplayTest(fcTest, property).prop( + [historyArbitrary], + oraclePropertyOptions(80, property), + )(`publishes one row per joined pair (random)`, runHistory) }) diff --git a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts index 694f1ab8a1..2fd7e2fc5e 100644 --- a/packages/db/tests/query/pooled-live-query-oracle.property.test.ts +++ b/packages/db/tests/query/pooled-live-query-oracle.property.test.ts @@ -80,8 +80,9 @@ import { createPooledLiveQuery } from '../../src/query/pooled-live-query.js' import { Func, PropRef, Value } from '../../src/query/ir.js' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../oracle-config.js' import { flushPromises, @@ -92,7 +93,6 @@ import { import type { ChangeMessage } from '../../src/types.js' const property = `pooled-live-query.publication` -const requestedReplayProperty = readOracleRunConfig().replayProperty const MISSING = Symbol(`missing`) type FieldValue = string | number | boolean | Date | null | typeof MISSING @@ -896,7 +896,10 @@ async function runHistory(history: History): Promise { } describe(`pooled live query oracle`, () => { - if (requestedReplayProperty === undefined) { + // The pinned histories and the fixed campaign stand aside during any path + // replay. The random campaign below is the one that a replay of this + // property selects. + if (!oracleReplayRequested()) { // Generated fields are plain values; this pins a field whose getter // throws, which both paths treat as a row that does not match. it(`matches the live-query Collection when an eq path getter throws`, async () => { @@ -942,16 +945,10 @@ describe(`pooled live query oracle`, () => { seed: 44_502_001, numRuns: oracleRuns(80), })(`matches the live-query Collection (fixed)`, runHistory) - fcTest.prop([historyArbitrary], oraclePropertyOptions(80, property))( - `matches the live-query Collection (random)`, - runHistory, - ) - } else if (requestedReplayProperty === property) { - fcTest.prop([historyArbitrary], oraclePropertyOptions(80, property))( - `matches the live-query Collection (replay)`, - runHistory, - ) - } else { - it.skip(`runs only when its replay property is selected`, () => {}) } + + oracleReplayTest(fcTest, property).prop( + [historyArbitrary], + oraclePropertyOptions(80, property), + )(`matches the live-query Collection (random)`, runHistory) }) diff --git a/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts b/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts index 67f05b11e7..e1740dd552 100644 --- a/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts +++ b/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts @@ -92,8 +92,9 @@ import { Func, PropRef, Value } from '../../src/query/ir.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../oracle-config.js' import { mockSyncCollectionOptions, @@ -104,7 +105,6 @@ import type { BasicExpression } from '../../src/query/ir.js' import type { ChangeMessage } from '../../src/types.js' const property = `where-predicate.publication` -const requestedReplayProperty = readOracleRunConfig().replayProperty // A string whose content equals an internal normalized-key prefix must still // compare as an ordinary string. @@ -863,7 +863,10 @@ function describePredicate(predicate: Predicate): string { // --------------------------------------------------------------------------- describe(`WHERE predicate publication oracle`, () => { - if (requestedReplayProperty === undefined) { + // The pinned histories and the fixed campaign stand aside during any path + // replay. The random campaign below is the one that a replay of this + // property selects. + if (!oracleReplayRequested()) { it(`pinned snapshot histories distinguish Kleene logic from two-valued logic`, () => { // A checker that folds UNKNOWN into FALSE before NOT must disagree with // the model on each pinned snapshot, or the history does not calibrate. @@ -1116,17 +1119,10 @@ describe(`WHERE predicate publication oracle`, () => { seed: 44_500_301, numRuns: oracleRuns(80), })(`publishes exactly the TRUE rows (fixed)`, runHistory) - - fcTest.prop([historyArbitrary], oraclePropertyOptions(80, property))( - `publishes exactly the TRUE rows (random)`, - runHistory, - ) - } else if (requestedReplayProperty === property) { - fcTest.prop([historyArbitrary], oraclePropertyOptions(80, property))( - `publishes exactly the TRUE rows (replay)`, - runHistory, - ) - } else { - it.skip(`runs only when its replay property is selected`, () => {}) } + + oracleReplayTest(fcTest, property).prop( + [historyArbitrary], + oraclePropertyOptions(80, property), + )(`publishes exactly the TRUE rows (random)`, runHistory) }) diff --git a/packages/db/tests/oracle-replay.fixture.test.ts b/packages/db/tests/replay-guard-calibration.fixture.test.ts similarity index 100% rename from packages/db/tests/oracle-replay.fixture.test.ts rename to packages/db/tests/replay-guard-calibration.fixture.test.ts diff --git a/packages/db/tests/utils-oracle.property.test.ts b/packages/db/tests/utils-oracle.property.test.ts index 2ad77a4632..3857d61052 100644 --- a/packages/db/tests/utils-oracle.property.test.ts +++ b/packages/db/tests/utils-oracle.property.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it } from 'vitest' import { fc, test as fcTest } from '@fast-check/vitest' import { Temporal } from 'temporal-polyfill' import { deepEquals, equalPersistedSnapshotValues } from '../src/utils' +import { + oraclePropertyOptions, + oracleReplayTest, + oracleRuns, +} from './oracle-config.js' /** * The `deepEquals` API contract in `../src/utils.ts`, reinforced by the @@ -29,56 +34,29 @@ import { deepEquals, equalPersistedSnapshotValues } from '../src/utils' // directly, for example with `vitest run tests/utils-oracle.property.test.ts`. const fixedSeed = 20260911 const campaignRuns = 100 -const requestedProperty = process.env.UTILS_ORACLE_PROPERTY -const replaySeed = process.env.UTILS_ORACLE_SEED -const replayPath = process.env.UTILS_ORACLE_PATH +// Each property registers as `utils.`, in file order. A path replay +// selects one with TANSTACK_DB_ORACLE_PROPERTY=utils.. let nextPropertyId = 0 -if ( - requestedProperty === undefined && - (replaySeed !== undefined || replayPath !== undefined) -) { - throw new Error(`utils oracle replay needs a property number`) -} - function utilsProperty]>(arbitraries: { [K in keyof Ts]: fc.Arbitrary }) { return (name: string, check: (...values: Ts) => void): void => { - const id = String(++nextPropertyId) + const id = ++nextPropertyId + const property = `utils.${id}` const label = `${id}: ${name}` // fast-check's variadic tuple overload widens a generic tuple to any[]. // The mapped arbitraries preserve each position's Ts member. const argumentsArbitrary = fc.tuple(...arbitraries) as fc.Arbitrary - if (requestedProperty !== undefined) { - if (requestedProperty !== id) { - it.skip(`${label} skipped for oracle replay`, () => {}) - return - } - if (replaySeed === undefined || replayPath === undefined) { - throw new Error(`utils oracle replay needs a seed and shrink path`) - } - const seed = Number(replaySeed) - if (!Number.isSafeInteger(seed)) { - throw new Error(`utils oracle replay seed must be an integer`) - } - fcTest.prop([argumentsArbitrary], { - seed, - path: replayPath, - numRuns: campaignRuns, - })(`${label} replay`, (values) => check(...values)) - return - } - - fcTest.prop([argumentsArbitrary], { + oracleReplayTest(fcTest).prop([argumentsArbitrary], { seed: fixedSeed, - numRuns: campaignRuns, + numRuns: oracleRuns(campaignRuns), })(`${label} fixed`, (values) => check(...values)) - fcTest.prop([argumentsArbitrary], { numRuns: campaignRuns })( - `${label} random`, - (values) => check(...values), - ) + oracleReplayTest(fcTest, property).prop( + [argumentsArbitrary], + oraclePropertyOptions(campaignRuns, property), + )(`${label} random`, (values) => check(...values)) } } @@ -1339,13 +1317,3 @@ describe(`persisted-snapshot prototype equality`, () => { ).toBe(false) }) }) - -if (requestedProperty !== undefined) { - it(`selects a known utils oracle property`, () => { - const id = Number(requestedProperty) - expect( - Number.isSafeInteger(id) && id >= 1 && id <= nextPropertyId, - `unknown utils oracle property: ${requestedProperty}`, - ).toBe(true) - }) -} 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..e7668be696 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 @@ -15,8 +15,9 @@ import { electricCollectionOptions } from '../src/electric' import { StreamAbortedError } from '../src/errors' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../../db/tests/oracle-config' import { atCheckpoint } from './electric-oracle-lifecycle' import { storageTargetId, tagPersistence } from './electric-persistence-fixture' @@ -51,9 +52,9 @@ import type { ElectricCollectionUtils } from '../src/electric' * framing or service behavior; those have separate owners. */ -const { replayPath, replayProperty } = readOracleRunConfig() -const fixedCase = replayPath === undefined ? it : it.skip -const fixedDescribe = replayPath === undefined ? describe : describe.skip +// Ordinary tests stand aside during any path replay, which runs one property. +const fixedCase = oracleReplayTest(it) +const fixedDescribe = oracleReplayTest(describe) // The durable fixture is a driver, not the independent tag model. It must // preserve the SQLite adapter's mutation algebra: an insert replaces the old @@ -612,24 +613,24 @@ fixedCase.each([ // TANSTACK_DB_ORACLE_PROPERTY=electric.persisted-tag-history // TANSTACK_DB_ORACLE_SEED= TANSTACK_DB_ORACLE_PATH= // pnpm exec vitest run tests/electric-descriptor-isolation-oracle.test.ts -if (replayPath === undefined) { - fcTest.prop([tagHistory], { seed: 42712, numRuns: oracleRuns(10) })( - `persisted tag histories preserve membership across warm and cold restart (fixed)`, - (history) => runTagHistory(history), - ) - fcTest.prop( +if (oracleReplayRequested()) { + oracleReplayTest(fcTest, `electric.persisted-tag-history`).prop( [tagHistory], oraclePropertyOptions(10, `electric.persisted-tag-history`), )( - `persisted tag histories preserve membership across warm and cold restart (random)`, + `persisted tag histories preserve membership across warm and cold restart (replay)`, + (history) => runTagHistory(history), + ) +} else { + fcTest.prop([tagHistory], { seed: 42712, numRuns: oracleRuns(10) })( + `persisted tag histories preserve membership across warm and cold restart (fixed)`, (history) => runTagHistory(history), ) -} else if (replayProperty === `electric.persisted-tag-history`) { fcTest.prop( [tagHistory], oraclePropertyOptions(10, `electric.persisted-tag-history`), )( - `persisted tag histories preserve membership across warm and cold restart (replay)`, + `persisted tag histories preserve membership across warm and cold restart (random)`, (history) => runTagHistory(history), ) } @@ -1748,24 +1749,24 @@ async function runOwnerHistory(history: { } } -if (replayPath === undefined) { - fcTest.prop([ownerHistory], { seed: 42711, numRuns: oracleRuns(12) })( - `config derivation preserves independent owner histories (fixed)`, - runOwnerHistory, - ) - fcTest.prop( +if (oracleReplayRequested()) { + oracleReplayTest(fcTest, `electric.bound-descriptor-history`).prop( [ownerHistory], oraclePropertyOptions(20, `electric.bound-descriptor-history`), )( - `config derivation preserves independent owner histories (random)`, + `config derivation preserves independent owner histories (replay)`, + runOwnerHistory, + ) +} else { + fcTest.prop([ownerHistory], { seed: 42711, numRuns: oracleRuns(12) })( + `config derivation preserves independent owner histories (fixed)`, runOwnerHistory, ) -} else if (replayProperty === `electric.bound-descriptor-history`) { fcTest.prop( [ownerHistory], oraclePropertyOptions(20, `electric.bound-descriptor-history`), )( - `config derivation preserves independent owner histories (replay)`, + `config derivation preserves independent owner histories (random)`, runOwnerHistory, ) } 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..b76cad3692 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -17,8 +17,9 @@ import { queryCollectionOptions } from '../../query-db-collection/src/query' import { electricCollectionOptions } from '../src/electric' import { oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, oracleRuns, - readOracleRunConfig, } from '../../db/tests/oracle-config' import { atCheckpoint, withElectricCleanup } from './electric-oracle-lifecycle' import { @@ -43,46 +44,12 @@ import type { RetryWait, } from './electric-retry-decision-model' -// Replays one generated law directly with -// TANSTACK_DB_ELECTRIC_HISTORY_PROPERTY= -// TANSTACK_DB_ELECTRIC_HISTORY_SEED= -// TANSTACK_DB_ELECTRIC_HISTORY_PATH= +// Replays one generated law directly with the shared oracle variables: +// TANSTACK_DB_ORACLE_PROPERTY= +// TANSTACK_DB_ORACLE_SEED= +// TANSTACK_DB_ORACLE_PATH= // pnpm exec vitest run tests/electric-oracle.property.test.ts -t ' \(replay\)' -const historyReplayProperty = process.env.TANSTACK_DB_ELECTRIC_HISTORY_PROPERTY -const historyReplaySeed = process.env.TANSTACK_DB_ELECTRIC_HISTORY_SEED -const historyReplayPath = process.env.TANSTACK_DB_ELECTRIC_HISTORY_PATH const registeredHistoryProperties = new Set() -const historyPropertyIds = new Set([ - `electric.initial-tagged-move-outs`, - `electric.process-grammar`, - `electric.design-convergence`, - `electric.synthetic-batch-partition`, - `electric.valid-batch-partition`, - `electric.invalid-resume-partition`, - `electric.subset-request-row-validity`, - `electric.resume-metadata-merge`, - `electric.metadata-merge-complete-state`, - `electric.retry-decision`, -]) - -if ( - historyReplayProperty === undefined && - (historyReplaySeed !== undefined || historyReplayPath !== undefined) -) { - throw new Error(`Electric history replay requires a property`) -} -if ( - historyReplayProperty !== undefined && - (historyReplaySeed === undefined || historyReplayPath === undefined) -) { - throw new Error(`Electric history replay requires both seed and path`) -} -if ( - historyReplayProperty !== undefined && - !historyPropertyIds.has(historyReplayProperty) -) { - throw new Error(`unknown Electric history property: ${historyReplayProperty}`) -} function electricHistoryProperty( property: string, @@ -98,35 +65,24 @@ function electricHistoryProperty( throw new Error(`duplicate Electric history property: ${property}`) } registeredHistoryProperties.add(property) - const numRuns = oracleRuns(baseRuns) - if (historyReplayProperty === undefined) { - fcTest.prop([arbitrary], { numRuns, seed: fixedSeed, examples })( - `${name} (fixed)`, - check, - timeout, - ) - fcTest.prop([arbitrary], { numRuns, examples })( - `${name} (random)`, - check, - timeout, - ) + if (!oracleReplayRequested()) { + fcTest.prop([arbitrary], { + numRuns: oracleRuns(baseRuns), + seed: fixedSeed, + examples, + })(`${name} (fixed)`, check, timeout) + fcTest.prop([arbitrary], { + ...oraclePropertyOptions(baseRuns, property), + examples, + })(`${name} (random)`, check, timeout) return } - if (historyReplayProperty !== property) return - const seed = Number(historyReplaySeed) - if (!Number.isSafeInteger(seed)) { - throw new Error(`Electric history replay seed must be an integer`) - } - if (!/^\d+(?::\d+)*$/.test(historyReplayPath!)) { - throw new Error( - `Electric history replay path must contain colon-separated nonnegative integers`, - ) - } - fcTest.prop([arbitrary], { numRuns, seed, path: historyReplayPath })( - `${name} (replay)`, - check, - timeout, - ) + // A path replay runs only the selected history. The registration stands + // aside for every other property. + oracleReplayTest(fcTest, property).prop([arbitrary], { + ...oraclePropertyOptions(baseRuns, property), + examples, + })(`${name} (replay)`, check, timeout) } /** @@ -383,58 +339,29 @@ function foldSourceLedgerThrough( throw new Error(`resume offset ${resumeOffset} is absent from source ledger`) } +const durablePrefixProperty = `electric.durable-prefix` +const publicationBeforePersistenceProperty = `electric.publication-before-persistence` +const adapterFailurePolicyProperty = `electric.adapter-failure-policy` + function durablePrefixPropertyOptions(mode: `fixed` | `random`): { numRuns: number seed?: number path?: string } { - const numRuns = oracleRuns(10) - if (mode === `fixed`) return { numRuns, seed: 20260921 } - const seedValue = process.env.TANSTACK_DB_ELECTRIC_DURABILITY_SEED - const path = process.env.TANSTACK_DB_ELECTRIC_DURABILITY_PATH - if (path !== undefined && seedValue === undefined) { - throw new Error( - `TANSTACK_DB_ELECTRIC_DURABILITY_PATH requires TANSTACK_DB_ELECTRIC_DURABILITY_SEED`, - ) - } - if (seedValue === undefined) return { numRuns } - const seed = Number(seedValue) - if (!Number.isSafeInteger(seed)) { - throw new Error(`TANSTACK_DB_ELECTRIC_DURABILITY_SEED must be an integer`) - } - return { - numRuns, - seed, - ...(path === undefined ? {} : { path }), - } + if (mode === `fixed`) return { numRuns: oracleRuns(10), seed: 20260921 } + return oraclePropertyOptions(10, durablePrefixProperty) } -function persistencePolicyPropertyOptions(mode: `fixed` | `random`): { +function persistencePolicyPropertyOptions( + mode: `fixed` | `random`, + property: string, +): { numRuns: number seed?: number path?: string } { - const numRuns = oracleRuns(8) - if (mode === `fixed`) return { numRuns, seed: 20260923 } - const seedValue = process.env.TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_SEED - const path = process.env.TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_PATH - if (path !== undefined && seedValue === undefined) { - throw new Error( - `TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_PATH requires TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_SEED`, - ) - } - if (seedValue === undefined) return { numRuns } - const seed = Number(seedValue) - if (!Number.isSafeInteger(seed)) { - throw new Error( - `TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_SEED must be an integer`, - ) - } - return { - numRuns, - seed, - ...(path === undefined ? {} : { path }), - } + if (mode === `fixed`) return { numRuns: oracleRuns(8), seed: 20260923 } + return oraclePropertyOptions(8, property) } async function runRejectedDurablePrefixWitness( @@ -711,22 +638,9 @@ const queuedPresenceArbitraries: [ }), fc.tuple(fc.string({ maxLength: 12 }), fc.string({ maxLength: 12 })), ] -const requestedQueuedPresenceProperty = - process.env.TANSTACK_DB_ELECTRIC_ORACLE_PROPERTY const persistenceInterleavingProperty = `electric.persistence-interleaving` -const requestedPersistenceInterleavingProperty = - readOracleRunConfig().replayProperty - -if ( - requestedQueuedPresenceProperty !== undefined && - requestedQueuedPresenceProperty !== queuedPresenceProperty -) { - throw new Error( - `unknown Electric oracle replay property: ${requestedQueuedPresenceProperty}`, - ) -} -function queuedPresencePropertyOptions(mode: `fixed` | `random` | `replay`): { +function queuedPresencePropertyOptions(mode: `fixed` | `random`): { numRuns: number seed?: number path?: string @@ -734,25 +648,7 @@ function queuedPresencePropertyOptions(mode: `fixed` | `random` | `replay`): { if (mode === `fixed`) { return { numRuns: queuedPresenceRuns, seed: queuedPresenceFixedSeed } } - if (mode === `random`) return { numRuns: queuedPresenceRuns } - - const seedValue = process.env.TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_SEED - const path = process.env.TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_PATH - if (seedValue === undefined || path === undefined || path.trim() === ``) { - throw new Error( - `Electric queued-presence replay requires both seed and path`, - ) - } - const seed = Number(seedValue) - if (!Number.isSafeInteger(seed)) { - throw new Error(`Electric queued-presence replay seed must be an integer`) - } - if (!/^\d+(?::\d+)*$/.test(path)) { - throw new Error( - `Electric queued-presence replay path must contain colon-separated nonnegative integers`, - ) - } - return { numRuns: queuedPresenceRuns, seed, path } + return oraclePropertyOptions(queuedPresenceRuns, queuedPresenceProperty) } function reconstructQueuedPresenceCampaign( @@ -2866,12 +2762,6 @@ async function runSchedulerPermutation( } } -const describeUnlessQueuedPresenceReplay = - requestedQueuedPresenceProperty || - requestedPersistenceInterleavingProperty === persistenceInterleavingProperty - ? describe.skip - : describe - function resetElectricOracleMocks(): void { vi.clearAllMocks() // clearAllMocks retains one-shot implementations from a failed earlier @@ -2883,7 +2773,9 @@ function resetElectricOracleMocks(): void { mockStream.lastOffset = `20_0` } -describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { +// Each registration below says which path replays stand it aside. A path +// replay runs only the selected property, and the sibling suites register it. +describe(`Electric adapter laws`, () => { let processGrammarRun = 0 let persistencePolicyRun = 0 @@ -2891,7 +2783,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { resetElectricOracleMocks() }) - it.each([false, true])( + oracleReplayTest(it).each([false, true])( `cleans direct traces after delivery failure and preserves the primary error (cleanup failure: %s)`, async (failCleanup) => { const primary = new Error(`delivery sentinel`) @@ -2929,7 +2821,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it.each( + oracleReplayTest(it).each( ([`eager`, `on-demand`, `progressive`] as const).flatMap((syncMode) => [0, 1, 2].map((removals) => ({ syncMode, removals })), ), @@ -2968,42 +2860,45 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it(`delivers retired callbacks without advancing a failed lifecycle`, async () => { - const result = await runProcessGrammar(`process-grammar-terminal-error`, [ - { - kind: `batch`, - slot: `a`, - operation: `update`, - id: 1, - name: `unseen`, - txid: 55, - }, - { kind: `snapshot`, slot: `a`, id: 1, name: `stale callback` }, - { - kind: `retired-callback`, - slot: `a`, - id: 1, - name: `actually delivered`, - }, - ]) - expect( - result.reached - .filter(({ command }) => command.kind === `retired-callback`) - .map(({ result: reached }) => reached), - ).toEqual([ - `delivered: retired callback`, - `delivered: retired callback`, - `delivered: retired callback`, - ]) - expect( - result.reached.find( - ({ command }) => - command.kind === `snapshot` && command.name === `stale callback`, - )?.result, - ).toBe(`skipped: terminal error`) - }) + oracleReplayTest(it)( + `delivers retired callbacks without advancing a failed lifecycle`, + async () => { + const result = await runProcessGrammar(`process-grammar-terminal-error`, [ + { + kind: `batch`, + slot: `a`, + operation: `update`, + id: 1, + name: `unseen`, + txid: 55, + }, + { kind: `snapshot`, slot: `a`, id: 1, name: `stale callback` }, + { + kind: `retired-callback`, + slot: `a`, + id: 1, + name: `actually delivered`, + }, + ]) + expect( + result.reached + .filter(({ command }) => command.kind === `retired-callback`) + .map(({ result: reached }) => reached), + ).toEqual([ + `delivered: retired callback`, + `delivered: retired callback`, + `delivered: retired callback`, + ]) + expect( + result.reached.find( + ({ command }) => + command.kind === `snapshot` && command.name === `stale callback`, + )?.result, + ).toBe(`skipped: terminal error`) + }, + ) - it.each([`invalid-resume`, `cleanup`] as const)( + oracleReplayTest(it).each([`invalid-resume`, `cleanup`] as const)( `records an initial preload's %s rejection before continuing the grammar`, async (failure) => { const result = await runProcessGrammar(`preload-${failure}`, [ @@ -3049,18 +2944,21 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it(`preserves unexpected preload failures instead of treating observation as success`, async () => { - const failure = new Error(`test-owned preload failure`) - const observed = observePreload(Promise.reject(failure)) - const outcome = await observed.outcome - expect(outcome).toEqual({ status: `rejected`, reason: failure }) - if (outcome.status === `rejected`) expect(outcome.reason).toBe(failure) - expect(() => expectPreloadOutcome(observed, outcome)).toThrow(failure) - const success = observePreload(Promise.resolve()) - expectPreloadOutcome(success, await success.outcome) - }) + oracleReplayTest(it)( + `preserves unexpected preload failures instead of treating observation as success`, + async () => { + const failure = new Error(`test-owned preload failure`) + const observed = observePreload(Promise.reject(failure)) + const outcome = await observed.outcome + expect(outcome).toEqual({ status: `rejected`, reason: failure }) + if (outcome.status === `rejected`) expect(outcome.reason).toBe(failure) + expect(() => expectPreloadOutcome(observed, outcome)).toThrow(failure) + const success = observePreload(Promise.resolve()) + expectPreloadOutcome(success, await success.outcome) + }, + ) - it.each([`reset`, `delete`, `move-out`] as const)( + oracleReplayTest(it).each([`reset`, `delete`, `move-out`] as const)( `keeps $0 removal authoritative across an optimistic write and a new acquisition`, async (removal) => { const trace = createOracleCollection( @@ -3121,217 +3019,232 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it(`publishes an Electric source commit before its adapter persistence settles`, async () => { - await runPublicationBeforePersistenceWitness( - `publication-before-persistence-fixed`, - { id: 41, name: `visible first`, stable: `stable-41` }, - 741, - ) - }) + oracleReplayTest(it)( + `publishes an Electric source commit before its adapter persistence settles`, + async () => { + await runPublicationBeforePersistenceWitness( + `publication-before-persistence-fixed`, + { id: 41, name: `visible first`, stable: `stable-41` }, + 741, + ) + }, + ) - it(`publishes later Electric callbacks while an earlier durability turn is held`, async () => { - let subscriber: ((messages: Array>) => void) | undefined - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const firstPersistenceEntered = createDeferred() - const releaseFirstPersistence = createDeferred() - const persistedMetadata = new Map() - const persistedRows = new Map() - const adapter = createPersistedAdapter(persistedMetadata, persistedRows) - const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) - let heldFirstRow = false - let durabilityCalls = 0 - adapter.applyCommittedTx = async (...args) => { - durabilityCalls++ - if ( - !heldFirstRow && - args[1].mutations.some((mutation) => mutation.key === 1) - ) { - heldFirstRow = true - firstPersistenceEntered.resolve() - await releaseFirstPersistence.promise + oracleReplayTest(it)( + `publishes later Electric callbacks while an earlier durability turn is held`, + async () => { + let subscriber: + ((messages: Array>) => void) | undefined + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const firstPersistenceEntered = createDeferred() + const releaseFirstPersistence = createDeferred() + const persistedMetadata = new Map() + const persistedRows = new Map() + const adapter = createPersistedAdapter(persistedMetadata, persistedRows) + const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) + let heldFirstRow = false + let durabilityCalls = 0 + adapter.applyCommittedTx = async (...args) => { + durabilityCalls++ + if ( + !heldFirstRow && + args[1].mutations.some((mutation) => mutation.key === 1) + ) { + heldFirstRow = true + firstPersistenceEntered.resolve() + await releaseFirstPersistence.promise + } + await applyCommittedTx(...args) } - await applyCommittedTx(...args) - } - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricCollectionOptions({ - id: `queued-persisted-presence`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - syncMode: `progressive`, - getKey: (row) => row.id, - startSync: true, + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `queued-persisted-presence`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `progressive`, + getKey: (row) => row.id, + startSync: true, + }), + persistence: { adapter }, }), - persistence: { adapter }, - }), - ) - const publications = observePublications(collection) - - await withElectricCleanup(async () => { - await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`), { - interval: 1, - timeout: 250, - }) - mockStream.lastOffset = `90_0` - subscriber!([upToDate]) - await vi.waitFor(() => expect(collection.status).toBe(`ready`), { - interval: 1, - timeout: 250, - }) - - const first = change(`insert`, 1, `holds persistence`) - first.value = { id: 1, name: `holds persistence`, stable: `stable-1` } - mockStream.lastOffset = `91_0` - subscriber!([first, upToDate]) - await atCheckpoint( - firstPersistenceEntered.promise, - `first Electric persistence entered`, ) - const durabilityCallsAtHold = durabilityCalls + const publications = observePublications(collection) - const queued = change(`insert`, 2, `queued insert`) - queued.value = { id: 2, name: `queued insert`, stable: `stable-2` } - mockStream.lastOffset = `92_0` - subscriber!([queued, upToDate]) + await withElectricCleanup(async () => { + await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`), { + interval: 1, + timeout: 250, + }) + mockStream.lastOffset = `90_0` + subscriber!([upToDate]) + await vi.waitFor(() => expect(collection.status).toBe(`ready`), { + interval: 1, + timeout: 250, + }) - const laterUpdate = change(`update`, 2, `later update`) - laterUpdate.value = { id: 2, name: `later update`, stable: `stable-2` } - mockStream.lastOffset = `93_0` - subscriber!([laterUpdate, upToDate]) + const first = change(`insert`, 1, `holds persistence`) + first.value = { id: 1, name: `holds persistence`, stable: `stable-1` } + mockStream.lastOffset = `91_0` + subscriber!([first, upToDate]) + await atCheckpoint( + firstPersistenceEntered.promise, + `first Electric persistence entered`, + ) + const durabilityCallsAtHold = durabilityCalls - const expected = rowsFromMap( - new Map([ - [1, first.value], - [2, laterUpdate.value], - ]), - ) - expect({ - publicRows: rowsFromCollection(collection), - durableRows: rowsFromMap(persistedRows), - noLaterDurabilityStarted: durabilityCalls === durabilityCallsAtHold, - published: publications.entries.some( - ({ cut, rows }) => - cut === `event` && isDeepStrictEqual(rows, expected), - ), - }).toEqual({ - publicRows: rowsFromMap(new Map([[1, first.value]])), - durableRows: [], - noLaterDurabilityStarted: true, - published: false, - }) + const queued = change(`insert`, 2, `queued insert`) + queued.value = { id: 2, name: `queued insert`, stable: `stable-2` } + mockStream.lastOffset = `92_0` + subscriber!([queued, upToDate]) - releaseFirstPersistence.resolve() - await vi.waitFor( - () => - expect(rowsFromMap(persistedRows)).toEqual( - rowsFromMap( - new Map([ - [1, first.value], - [2, laterUpdate.value], - ]), - ), - ), - { interval: 1, timeout: 250 }, - ) - expect(rowsFromCollection(collection)).toEqual( - rowsFromMap( + const laterUpdate = change(`update`, 2, `later update`) + laterUpdate.value = { id: 2, name: `later update`, stable: `stable-2` } + mockStream.lastOffset = `93_0` + subscriber!([laterUpdate, upToDate]) + + const expected = rowsFromMap( new Map([ [1, first.value], [2, laterUpdate.value], ]), - ), - ) - expect(collection.status).toBe(`ready`) - }, [ - () => releaseFirstPersistence.resolve(), - () => publications.stop(), - () => collection.cleanup(), - ]) - }) + ) + expect({ + publicRows: rowsFromCollection(collection), + durableRows: rowsFromMap(persistedRows), + noLaterDurabilityStarted: durabilityCalls === durabilityCallsAtHold, + published: publications.entries.some( + ({ cut, rows }) => + cut === `event` && isDeepStrictEqual(rows, expected), + ), + }).toEqual({ + publicRows: rowsFromMap(new Map([[1, first.value]])), + durableRows: [], + noLaterDurabilityStarted: true, + published: false, + }) - it(`reconstructs the queued-presence grammar and rejects ablated, out-of-range, and foreign campaigns`, () => { - const campaigns = fc.sample(fc.tuple(...queuedPresenceArbitraries), { - seed: queuedPresenceFixedSeed, - numRuns: 20, - }) - for (const [histories, names] of campaigns) { - expect(reconstructQueuedPresenceCampaign({ histories, names })).toEqual({ - histories, - names, + releaseFirstPersistence.resolve() + await vi.waitFor( + () => + expect(rowsFromMap(persistedRows)).toEqual( + rowsFromMap( + new Map([ + [1, first.value], + [2, laterUpdate.value], + ]), + ), + ), + { interval: 1, timeout: 250 }, + ) + expect(rowsFromCollection(collection)).toEqual( + rowsFromMap( + new Map([ + [1, first.value], + [2, laterUpdate.value], + ]), + ), + ) + expect(collection.status).toBe(`ready`) + }, [ + () => releaseFirstPersistence.resolve(), + () => publications.stop(), + () => collection.cleanup(), + ]) + }, + ) + + oracleReplayTest(it)( + `reconstructs the queued-presence grammar and rejects ablated, out-of-range, and foreign campaigns`, + () => { + const campaigns = fc.sample(fc.tuple(...queuedPresenceArbitraries), { + seed: queuedPresenceFixedSeed, + numRuns: 20, }) - } + for (const [histories, names] of campaigns) { + expect(reconstructQueuedPresenceCampaign({ histories, names })).toEqual( + { + histories, + names, + }, + ) + } - const witness = { - histories: [...queuedPresenceHistories], - names: [``, `x`.repeat(12)] as [string, string], - } - expect(reconstructQueuedPresenceCampaign(witness)).toEqual(witness) - expect(() => - reconstructQueuedPresenceCampaign({ names: witness.names }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ histories: witness.histories }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - histories: witness.histories.slice(1), - }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - histories: [ - `insert-update`, - `insert-update`, - `delete-update`, - `truncate-update`, - ], - }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - histories: [ - `insert-update`, - `update-update`, - `delete-update`, - `replace-update`, - ], - }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - names: [`x`.repeat(13), ``], - }), - ).toThrow() - }) + const witness = { + histories: [...queuedPresenceHistories], + names: [``, `x`.repeat(12)] as [string, string], + } + expect(reconstructQueuedPresenceCampaign(witness)).toEqual(witness) + expect(() => + reconstructQueuedPresenceCampaign({ names: witness.names }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ histories: witness.histories }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + histories: witness.histories.slice(1), + }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + histories: [ + `insert-update`, + `insert-update`, + `delete-update`, + `truncate-update`, + ], + }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + histories: [ + `insert-update`, + `update-update`, + `delete-update`, + `replace-update`, + ], + }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + names: [`x`.repeat(13), ``], + }), + ).toThrow() + }, + ) - it(`rejects named wrong answer: later callback publishes before durability`, () => { - expect(() => - expectQueuedPresenceAtDurabilityHold( - [ - [1, `holds persistence`, `stable-1`], - [2, `later`, `stable-2`], - ], - `insert-update`, - { id: 1, name: `holds persistence`, stable: `stable-1` }, - ), - ).toThrow() - }) + oracleReplayTest(it)( + `rejects named wrong answer: later callback publishes before durability`, + () => { + expect(() => + expectQueuedPresenceAtDurabilityHold( + [ + [1, `holds persistence`, `stable-1`], + [2, `later`, `stable-2`], + ], + `insert-update`, + { id: 1, name: `holds persistence`, stable: `stable-1` }, + ), + ).toThrow() + }, + ) - fcTest.prop( + oracleReplayTest(fcTest).prop( queuedPresenceArbitraries, queuedPresencePropertyOptions(`fixed`), )( @@ -3339,7 +3252,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { runQueuedPresenceCampaign, ) - fcTest.prop( + oracleReplayTest(fcTest).prop( queuedPresenceArbitraries, queuedPresencePropertyOptions(`random`), )( @@ -3359,29 +3272,37 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { 800 + id, ) } - fcTest.prop( + oracleReplayTest(fcTest).prop( publicationPolicyArbitraries, - persistencePolicyPropertyOptions(`fixed`), + persistencePolicyPropertyOptions( + `fixed`, + publicationBeforePersistenceProperty, + ), )( `generated Electric commits publish before adapter persistence settles (fixed)`, checkPublicationPolicy, ) - // Replay the random lane with the legacy seed/path variables and -t 'random'. - fcTest.prop( + oracleReplayTest(fcTest, publicationBeforePersistenceProperty).prop( publicationPolicyArbitraries, - persistencePolicyPropertyOptions(`random`), + persistencePolicyPropertyOptions( + `random`, + publicationBeforePersistenceProperty, + ), )( `generated Electric commits publish before adapter persistence settles (random)`, checkPublicationPolicy, ) - it(`turns adapter rejection into a named terminal Electric persistence error`, async () => { - await runPersistenceFailureCrashOnlyWitness( - `terminal-persistence-error-fixed`, - { id: 51, name: `published before failure`, stable: `stable-51` }, - { id: 52, name: `must not be admitted`, stable: `stable-52` }, - ) - }) + oracleReplayTest(it)( + `turns adapter rejection into a named terminal Electric persistence error`, + async () => { + await runPersistenceFailureCrashOnlyWitness( + `terminal-persistence-error-fixed`, + { id: 51, name: `published before failure`, stable: `stable-51` }, + { id: 52, name: `must not be admitted`, stable: `stable-52` }, + ) + }, + ) const failurePolicyArbitraries: [ fc.Arbitrary, @@ -3398,16 +3319,16 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { { id: id + 100, name: names[1], stable: `stable-${id + 100}` }, ) } - fcTest.prop( + oracleReplayTest(fcTest).prop( failurePolicyArbitraries, - persistencePolicyPropertyOptions(`fixed`), + persistencePolicyPropertyOptions(`fixed`, adapterFailurePolicyProperty), )( `generated adapter failures reject publicly and admit no later Electric work (fixed)`, checkFailurePolicy, ) - fcTest.prop( + oracleReplayTest(fcTest, adapterFailurePolicyProperty).prop( failurePolicyArbitraries, - persistencePolicyPropertyOptions(`random`), + persistencePolicyPropertyOptions(`random`, adapterFailurePolicyProperty), )( `generated adapter failures reject publicly and admit no later Electric work (random)`, checkFailurePolicy, @@ -3450,148 +3371,164 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it(`distinguishes plausible unseen-update and reset-visibility semantics`, async () => { - const completeUnseenUpdate: ChangeMessage = { - key: `1`, - value: { id: 1, name: `complete`, stable: `stable-1` }, - headers: { operation: `update` }, - } - const updateHistory = [[completeUnseenUpdate, upToDate]] - expect(recomputeCommittedRows(updateHistory, `ignore`)).toEqual([]) - expect(recomputeCommittedRows(updateHistory, `promote-complete`)).toEqual([ - [1, `complete`, `stable-1`], - ]) - const updateActual = await runTrace( - `design-unseen-update`, - `eager`, - [], - updateHistory, - ) - expect(updateActual.rows).toEqual( - recomputeCommittedRows(updateHistory, `ignore`), - ) - - const resetHistory = [ - [change(`insert`, 1, `committed`), upToDate], - [mustRefetch], - ] - const atomicReset = recomputedSnapshots([], resetHistory) - const immediateReset: typeof atomicReset = [atomicReset[0]!, []] - expect(atomicReset).not.toEqual(immediateReset) - const resetActual = await runTrace( - `design-reset-visibility`, - `eager`, - [], - resetHistory, - ) - expect(resetActual.snapshots).toEqual(atomicReset) - }) - - it(`keeps SDK reset callbacks separate from every neighboring message kind`, () => { - const neighbors: Array> = [ - change(`insert`, 1, `row`), - change(`update`, 1, `row`), - change(`delete`, 1, `row`), - { headers: { event: `move-out`, patterns: [{ pos: 0, value: `left` }] } }, - upToDate, - subsetEnd, - mustRefetch, - ] - expect(isSdkResetFramedPartition([[mustRefetch]])).toBe(true) - for (const neighbor of neighbors) { - for (const messages of [ - [neighbor, mustRefetch], - [mustRefetch, neighbor], - ]) { - expect( - isSdkResetFramedPartition([messages]), - JSON.stringify(messages), - ).toBe(false) - expect( - isSdkResetFramedPartition(messages.map((message) => [message])), - ).toBe(true) + oracleReplayTest(it)( + `distinguishes plausible unseen-update and reset-visibility semantics`, + async () => { + const completeUnseenUpdate: ChangeMessage = { + key: `1`, + value: { id: 1, name: `complete`, stable: `stable-1` }, + headers: { operation: `update` }, } - } - }) - - it(`distinguishes callback-atomic and subset publication semantics`, async () => { - const callbackHistory = [ - [ - change(`insert`, 1, `before-control`), - upToDate, - change(`update`, 1, `after-control`), - ], - ] - const callbackAtomic = recomputeCommittedRows(callbackHistory) - const freezeAtControl: typeof callbackAtomic = [ - [1, `before-control`, `stable-1`], - ] - expect(callbackAtomic).toEqual([[1, `after-control`, `stable-1`]]) - expect(callbackAtomic).not.toEqual(freezeAtControl) - expect(isSdkResetFramedPartition([[upToDate, mustRefetch]])).toBe(false) - expect( - isSdkResetFramedPartition([[mustRefetch, subsetEnd, mustRefetch]]), - ).toBe(false) - - const readyPrefix = [[upToDate]] - const subsetHistory = [ - [change(`insert`, 1, `subset-publication`)], - [subsetEnd], - ] - const subsetPublishes = recomputedSnapshots(readyPrefix, subsetHistory) - const upToDateOnly: typeof subsetPublishes = [[], []] - expect(subsetPublishes).not.toEqual(upToDateOnly) - - for (const syncMode of [`eager`, `on-demand`, `progressive`] as const) { - const callbackActual = await runTrace( - `design-callback-atomic-${syncMode}`, - syncMode, + const updateHistory = [[completeUnseenUpdate, upToDate]] + expect(recomputeCommittedRows(updateHistory, `ignore`)).toEqual([]) + expect(recomputeCommittedRows(updateHistory, `promote-complete`)).toEqual( + [[1, `complete`, `stable-1`]], + ) + const updateActual = await runTrace( + `design-unseen-update`, + `eager`, [], - callbackHistory, + updateHistory, + ) + expect(updateActual.rows).toEqual( + recomputeCommittedRows(updateHistory, `ignore`), ) - expect(callbackActual.rows).toEqual(callbackAtomic) - const subsetActual = await runTrace( - `design-subset-publication-${syncMode}`, - syncMode, - readyPrefix, - subsetHistory, + const resetHistory = [ + [change(`insert`, 1, `committed`), upToDate], + [mustRefetch], + ] + const atomicReset = recomputedSnapshots([], resetHistory) + const immediateReset: typeof atomicReset = [atomicReset[0]!, []] + expect(atomicReset).not.toEqual(immediateReset) + const resetActual = await runTrace( + `design-reset-visibility`, + `eager`, + [], + resetHistory, ) - expect(subsetActual.snapshots).toEqual(subsetPublishes) - } - }) + expect(resetActual.snapshots).toEqual(atomicReset) + }, + ) - it(`settles every startup, hydration, snapshot availability, persistence-apply release, and cleanup permutation`, async () => { - const events: Array = [ - `startup-promise`, - `hydration`, - `snapshot-available`, - `release-persistence-apply`, - `cleanup`, - ] - let permutationIndex = 0 - const outcomes = new Set<`resolved` | `aborted`>() - let sawAcknowledgementBeforeDurability = false - for (const order of permutations(events)) { - const currentIndex = permutationIndex++ - try { - const result = await runSchedulerPermutation( - `scheduler-permutation-${currentIndex}`, - order, - ) - outcomes.add(result.outcome) - sawAcknowledgementBeforeDurability ||= result.acknowledgedBeforeDurable - } catch (error) { - throw new Error( - `scheduler permutation ${currentIndex} failed: ${order.join(` → `)}`, - { cause: error }, - ) + oracleReplayTest(it)( + `keeps SDK reset callbacks separate from every neighboring message kind`, + () => { + const neighbors: Array> = [ + change(`insert`, 1, `row`), + change(`update`, 1, `row`), + change(`delete`, 1, `row`), + { + headers: { event: `move-out`, patterns: [{ pos: 0, value: `left` }] }, + }, + upToDate, + subsetEnd, + mustRefetch, + ] + expect(isSdkResetFramedPartition([[mustRefetch]])).toBe(true) + for (const neighbor of neighbors) { + for (const messages of [ + [neighbor, mustRefetch], + [mustRefetch, neighbor], + ]) { + expect( + isSdkResetFramedPartition([messages]), + JSON.stringify(messages), + ).toBe(false) + expect( + isSdkResetFramedPartition(messages.map((message) => [message])), + ).toBe(true) + } } - } - expect(outcomes).toEqual(new Set([`resolved`, `aborted`])) - expect(sawAcknowledgementBeforeDurability).toBe(true) - }, 30_000) - + }, + ) + + oracleReplayTest(it)( + `distinguishes callback-atomic and subset publication semantics`, + async () => { + const callbackHistory = [ + [ + change(`insert`, 1, `before-control`), + upToDate, + change(`update`, 1, `after-control`), + ], + ] + const callbackAtomic = recomputeCommittedRows(callbackHistory) + const freezeAtControl: typeof callbackAtomic = [ + [1, `before-control`, `stable-1`], + ] + expect(callbackAtomic).toEqual([[1, `after-control`, `stable-1`]]) + expect(callbackAtomic).not.toEqual(freezeAtControl) + expect(isSdkResetFramedPartition([[upToDate, mustRefetch]])).toBe(false) + expect( + isSdkResetFramedPartition([[mustRefetch, subsetEnd, mustRefetch]]), + ).toBe(false) + + const readyPrefix = [[upToDate]] + const subsetHistory = [ + [change(`insert`, 1, `subset-publication`)], + [subsetEnd], + ] + const subsetPublishes = recomputedSnapshots(readyPrefix, subsetHistory) + const upToDateOnly: typeof subsetPublishes = [[], []] + expect(subsetPublishes).not.toEqual(upToDateOnly) + + for (const syncMode of [`eager`, `on-demand`, `progressive`] as const) { + const callbackActual = await runTrace( + `design-callback-atomic-${syncMode}`, + syncMode, + [], + callbackHistory, + ) + expect(callbackActual.rows).toEqual(callbackAtomic) + + const subsetActual = await runTrace( + `design-subset-publication-${syncMode}`, + syncMode, + readyPrefix, + subsetHistory, + ) + expect(subsetActual.snapshots).toEqual(subsetPublishes) + } + }, + ) + + oracleReplayTest(it)( + `settles every startup, hydration, snapshot availability, persistence-apply release, and cleanup permutation`, + async () => { + const events: Array = [ + `startup-promise`, + `hydration`, + `snapshot-available`, + `release-persistence-apply`, + `cleanup`, + ] + let permutationIndex = 0 + const outcomes = new Set<`resolved` | `aborted`>() + let sawAcknowledgementBeforeDurability = false + for (const order of permutations(events)) { + const currentIndex = permutationIndex++ + try { + const result = await runSchedulerPermutation( + `scheduler-permutation-${currentIndex}`, + order, + ) + outcomes.add(result.outcome) + sawAcknowledgementBeforeDurability ||= + result.acknowledgedBeforeDurable + } catch (error) { + throw new Error( + `scheduler permutation ${currentIndex} failed: ${order.join(` → `)}`, + { cause: error }, + ) + } + } + expect(outcomes).toEqual(new Set([`resolved`, `aborted`])) + expect(sawAcknowledgementBeforeDurability).toBe(true) + }, + 30_000, + ) + electricHistoryProperty( `electric.synthetic-batch-partition`, `synthetic batch partition is invariant across Electric modes and phases`, @@ -3798,107 +3735,110 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it(`keeps pending row presence across callbacks until one persisted subset commit`, async () => { - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const persistedRows = new Map() - const persistedMetadata = new Map() - const adapter = createPersistedAdapter(persistedMetadata, persistedRows) - const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) - const durabilityEntered = createDeferred() - const releaseDurability = createDeferred() - const durabilitySettled = createDeferred() - let persistedTx: PersistedTx | undefined - adapter.applyCommittedTx = async (...args) => { - persistedTx = structuredClone(args[1]) - durabilityEntered.resolve() - await releaseDurability.promise - await applyCommittedTx(...args) - durabilitySettled.resolve() - } - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricCollectionOptions({ - id: `persisted-cross-callback-presence`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - syncMode: `eager`, - getKey: (row) => row.id, - startSync: true, + oracleReplayTest(it)( + `keeps pending row presence across callbacks until one persisted subset commit`, + async () => { + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const persistedRows = new Map() + const persistedMetadata = new Map() + const adapter = createPersistedAdapter(persistedMetadata, persistedRows) + const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) + const durabilityEntered = createDeferred() + const releaseDurability = createDeferred() + const durabilitySettled = createDeferred() + let persistedTx: PersistedTx | undefined + adapter.applyCommittedTx = async (...args) => { + persistedTx = structuredClone(args[1]) + durabilityEntered.resolve() + await releaseDurability.promise + await applyCommittedTx(...args) + durabilitySettled.resolve() + } + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `persisted-cross-callback-presence`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `eager`, + getKey: (row) => row.id, + startSync: true, + }), + persistence: { adapter }, }), - persistence: { adapter }, - }), - ) - - collection.startSyncImmediate() - await withElectricCleanup(async () => { - await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) - - subscriber([change(`insert`, 1, ``)]) - subscriber([change(`update`, 1, `updated across callbacks`)]) - subscriber([subsetEnd]) - await atCheckpoint( - durabilityEntered.promise, - `cross-callback durability entered`, ) - expect({ - publicRows: rowsFromCollection(collection), - durableRows: rowsFromMap(persistedRows), - persistedMutations: persistedTx?.mutations.map((mutation) => ({ - type: mutation.type, - key: mutation.key, - value: mutation.type === `delete` ? undefined : mutation.value, - })), - }).toEqual({ - publicRows: [[1, `updated across callbacks`, `stable-1`]], - durableRows: [], - persistedMutations: [ - { - type: `update`, - key: 1, - value: { - id: 1, - name: ``, - stable: `stable-1`, + collection.startSyncImmediate() + await withElectricCleanup(async () => { + await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) + + subscriber([change(`insert`, 1, ``)]) + subscriber([change(`update`, 1, `updated across callbacks`)]) + subscriber([subsetEnd]) + await atCheckpoint( + durabilityEntered.promise, + `cross-callback durability entered`, + ) + + expect({ + publicRows: rowsFromCollection(collection), + durableRows: rowsFromMap(persistedRows), + persistedMutations: persistedTx?.mutations.map((mutation) => ({ + type: mutation.type, + key: mutation.key, + value: mutation.type === `delete` ? undefined : mutation.value, + })), + }).toEqual({ + publicRows: [[1, `updated across callbacks`, `stable-1`]], + durableRows: [], + persistedMutations: [ + { + type: `update`, + key: 1, + value: { + id: 1, + name: ``, + stable: `stable-1`, + }, }, - }, - { - type: `update`, - key: 1, - value: { - id: 1, - name: `updated across callbacks`, + { + type: `update`, + key: 1, + value: { + id: 1, + name: `updated across callbacks`, + }, }, - }, - ], - }) + ], + }) - releaseDurability.resolve() - await atCheckpoint( - durabilitySettled.promise, - `cross-callback durability settled`, - ) - expect(rowsFromMap(persistedRows)).toEqual([ - [1, `updated across callbacks`, `stable-1`], - ]) - }, [ - async () => { releaseDurability.resolve() - await collection.cleanup() - }, - ]) - }) + await atCheckpoint( + durabilitySettled.promise, + `cross-callback durability settled`, + ) + expect(rowsFromMap(persistedRows)).toEqual([ + [1, `updated across callbacks`, `stable-1`], + ]) + }, [ + async () => { + releaseDurability.resolve() + await collection.cleanup() + }, + ]) + }, + ) const publicationEpochTokensArbitrary = fc.array(designTokenArb, { minLength: 1, @@ -3969,13 +3909,13 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ], ] - it.each(publicationEpochExamples)( + oracleReplayTest(it).each(publicationEpochExamples)( `reconstructs the authored publication-epoch history`, assertPublicationEpochHistory, 30_000, ) - fcTest.prop( + oracleReplayTest(fcTest).prop( [publicationEpochTokensArbitrary, publicationEpochPartitionArbitrary], { seed: 42714, @@ -3987,7 +3927,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { 30_000, ) - fcTest.prop( + oracleReplayTest(fcTest, `electric.publication-epoch-convergence`).prop( [publicationEpochTokensArbitrary, publicationEpochPartitionArbitrary], oraclePropertyOptions(20, `electric.publication-epoch-convergence`), )( @@ -3996,176 +3936,182 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { 30_000, ) - it(`retains a wrong intermediate publication even when the final read repairs it`, async () => { - const trace = createOracleCollection( - `publication-recorder-control`, - `eager`, - createMetadata(new Map()).api, - ) - trace.subscriber([change(`insert`, 1, `old`), upToDate]) - const observed = observePublications(trace.collection) - try { - observed.record(`before`) - trace.subscriber([change(`update`, 1, `wrong`), upToDate]) - trace.subscriber([change(`update`, 1, `correct`), upToDate]) - observed.record(`final`) - expect(rowsFromCollection(trace.collection)).toEqual([ - [1, `correct`, `stable-1`], - ]) - expect(observed.entries[0]!.rows).toEqual([[1, `old`, `stable-1`]]) - expect( - observed.entries.some( - ({ cut, rows }) => cut === `event` && rows[0]?.[1] === `wrong`, - ), - ).toBe(true) - expect(() => + oracleReplayTest(it)( + `retains a wrong intermediate publication even when the final read repairs it`, + async () => { + const trace = createOracleCollection( + `publication-recorder-control`, + `eager`, + createMetadata(new Map()).api, + ) + trace.subscriber([change(`insert`, 1, `old`), upToDate]) + const observed = observePublications(trace.collection) + try { + observed.record(`before`) + trace.subscriber([change(`update`, 1, `wrong`), upToDate]) + trace.subscriber([change(`update`, 1, `correct`), upToDate]) + observed.record(`final`) + expect(rowsFromCollection(trace.collection)).toEqual([ + [1, `correct`, `stable-1`], + ]) + expect(observed.entries[0]!.rows).toEqual([[1, `old`, `stable-1`]]) + expect( + observed.entries.some( + ({ cut, rows }) => cut === `event` && rows[0]?.[1] === `wrong`, + ), + ).toBe(true) + expect(() => + expectWholePublications(observed.entries, [ + [[1, `old`, `stable-1`]], + [[1, `correct`, `stable-1`]], + ]), + ).toThrow() expectWholePublications(observed.entries, [ [[1, `old`, `stable-1`]], + [[1, `wrong`, `stable-1`]], [[1, `correct`, `stable-1`]], - ]), - ).toThrow() - expectWholePublications(observed.entries, [ - [[1, `old`, `stable-1`]], - [[1, `wrong`, `stable-1`]], - [[1, `correct`, `stable-1`]], - ]) - } finally { - observed.stop() - await trace.collection.cleanup() - } - }) + ]) + } finally { + observed.stop() + await trace.collection.cleanup() + } + }, + ) - it(`checks an authored HTTP transcript and separate complete Query snapshots`, async () => { - const { ShapeStream: RealShapeStream } = await vi.importActual<{ - ShapeStream: typeof ShapeStream - }>(`@electric-sql/client`) - const ready: Message = { - headers: { control: `up-to-date`, global_last_seen_lsn: `2` }, - } - const authored: Array>> = [ - [ - change(`insert`, 1, `old`), - { headers: { control: `up-to-date`, global_last_seen_lsn: `1` } }, - ], - [mustRefetch], - [ - change(`insert`, 2, `replacement`), - subsetEnd, - change(`insert`, 3, `after-control`), - ready, - ], - ] - // These complete snapshots are authored directly, not computed by either - // reference reducer or from any adapter result. A post-control row belongs - // to the same delivered callback's atomic publication. - const complete: TraceResult[`snapshots`] = [ - [[1, `old`, `stable-1`]], - [[1, `old`, `stable-1`]], - [ - [2, `replacement`, `stable-2`], - [3, `after-control`, `stable-3`], - ], - ] - const controller = new AbortController() - const headers = { - 'electric-handle': `authored-old`, - 'electric-offset': `1_0`, - // Without this completion header the SDK can prefetch the next chunk; - // a positional mock-response queue would not describe the live request. - 'electric-up-to-date': `true`, - 'electric-schema': JSON.stringify({ - id: { type: `int4` }, - name: { type: `text` }, - stable: { type: `text` }, - }), - } - const wire = (messages: Array>) => - JSON.stringify( - messages.map((message) => - `value` in message - ? { - ...message, - value: { ...message.value, id: String(message.value.id) }, - } - : message, - ), - ) - const responses = [ - new Response(wire(authored[0]!), { headers }), - new Response(wire(authored[1]!), { - status: 409, - headers: { 'electric-handle': `authored-new` }, - }), - new Response(wire(authored[2]!), { - headers: { - ...headers, - 'electric-handle': `authored-new`, - 'electric-offset': `2_0`, - 'electric-cursor': `1`, + oracleReplayTest(it)( + `checks an authored HTTP transcript and separate complete Query snapshots`, + async () => { + const { ShapeStream: RealShapeStream } = await vi.importActual<{ + ShapeStream: typeof ShapeStream + }>(`@electric-sql/client`) + const ready: Message = { + headers: { control: `up-to-date`, global_last_seen_lsn: `2` }, + } + const authored: Array>> = [ + [ + change(`insert`, 1, `old`), + { headers: { control: `up-to-date`, global_last_seen_lsn: `1` } }, + ], + [mustRefetch], + [ + change(`insert`, 2, `replacement`), + subsetEnd, + change(`insert`, 3, `after-control`), + ready, + ], + ] + // These complete snapshots are authored directly, not computed by either + // reference reducer or from any adapter result. A post-control row belongs + // to the same delivered callback's atomic publication. + const complete: TraceResult[`snapshots`] = [ + [[1, `old`, `stable-1`]], + [[1, `old`, `stable-1`]], + [ + [2, `replacement`, `stable-2`], + [3, `after-control`, `stable-3`], + ], + ] + const controller = new AbortController() + const headers = { + 'electric-handle': `authored-old`, + 'electric-offset': `1_0`, + // Without this completion header the SDK can prefetch the next chunk; + // a positional mock-response queue would not describe the live request. + 'electric-up-to-date': `true`, + 'electric-schema': JSON.stringify({ + id: { type: `int4` }, + name: { type: `text` }, + stable: { type: `text` }, + }), + } + const wire = (messages: Array>) => + JSON.stringify( + messages.map((message) => + `value` in message + ? { + ...message, + value: { ...message.value, id: String(message.value.id) }, + } + : message, + ), + ) + const responses = [ + new Response(wire(authored[0]!), { headers }), + new Response(wire(authored[1]!), { + status: 409, + headers: { 'electric-handle': `authored-new` }, + }), + new Response(wire(authored[2]!), { + headers: { + ...headers, + 'electric-handle': `authored-new`, + 'electric-offset': `2_0`, + 'electric-cursor': `1`, + }, + }), + ] + const received: Array>> = [] + const stream = new RealShapeStream({ + url: `http://test-url/v1/authored-publication-transcript`, + params: { table: `rows` }, + signal: controller.signal, + fetchClient: () => { + const response = responses.shift() + if (response) return Promise.resolve(response) + return new Promise((_resolve, reject) => { + if (controller.signal.aborted) reject(controller.signal.reason) + else + controller.signal.addEventListener( + `abort`, + () => reject(controller.signal.reason), + { once: true }, + ) + }) }, - }), - ] - const received: Array>> = [] - const stream = new RealShapeStream({ - url: `http://test-url/v1/authored-publication-transcript`, - params: { table: `rows` }, - signal: controller.signal, - fetchClient: () => { - const response = responses.shift() - if (response) return Promise.resolve(response) - return new Promise((_resolve, reject) => { - if (controller.signal.aborted) reject(controller.signal.reason) - else - controller.signal.addEventListener( - `abort`, - () => reject(controller.signal.reason), - { once: true }, - ) - }) - }, - }) - const unsubscribe = stream.subscribe((messages) => { - received.push(structuredClone(messages)) - }) - try { - await vi.waitFor(() => - expect( - received, - JSON.stringify({ received, remainingResponses: responses.length }), - ).toHaveLength(3), - ) - expect(received).toEqual(authored) - expect(isSdkResetFramedPartition(received)).toBe(true) - // This validates this installed SDK's controlled-HTTP antecedent only. - // It is not proof that all servers or SSE streams emit this transcript. - for (const mode of [`eager`, `on-demand`, `progressive`] as const) { - const direct = await runTrace( - `authored-direct-${mode}`, - mode, - [], - structuredClone(received), + }) + const unsubscribe = stream.subscribe((messages) => { + received.push(structuredClone(messages)) + }) + try { + await vi.waitFor(() => + expect( + received, + JSON.stringify({ received, remainingResponses: responses.length }), + ).toHaveLength(3), ) - const persisted = await runPersistedTrace( - `authored-persisted-${mode}`, - mode, + expect(received).toEqual(authored) + expect(isSdkResetFramedPartition(received)).toBe(true) + // This validates this installed SDK's controlled-HTTP antecedent only. + // It is not proof that all servers or SSE streams emit this transcript. + for (const mode of [`eager`, `on-demand`, `progressive`] as const) { + const direct = await runTrace( + `authored-direct-${mode}`, + mode, + [], + structuredClone(received), + ) + const persisted = await runPersistedTrace( + `authored-persisted-${mode}`, + mode, + structuredClone(received), + complete, + ) + expect(direct.snapshots).toEqual(complete) + expect(persisted.snapshots).toEqual(complete) + expect(persisted.durableRows).toEqual(complete[2]) + } + const query = await runQueryTrace( + `authored-query`, structuredClone(received), complete, ) - expect(direct.snapshots).toEqual(complete) - expect(persisted.snapshots).toEqual(complete) - expect(persisted.durableRows).toEqual(complete[2]) + expect(query.snapshots).toEqual(complete) + } finally { + unsubscribe() + controller.abort() } - const query = await runQueryTrace( - `authored-query`, - structuredClone(received), - complete, - ) - expect(query.snapshots).toEqual(complete) - } finally { - unsubscribe() - controller.abort() - } - }) + }, + ) electricHistoryProperty( `electric.invalid-resume-partition`, @@ -4318,356 +4264,340 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }) } - it(`keeps stream cleanup and stale callbacks scoped to their lifecycle`, async () => { - const subscribers: Array<(messages: Array>) => void> = [] - const unsubscribes = [vi.fn(), vi.fn()] - mockSubscribe.mockImplementation((callback) => { - const index = subscribers.length - subscribers.push(callback) - return unsubscribes[index]! - }) - const options = electricCollectionOptions({ - id: `reused-sync-config`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: true, - }) - const createLifecycle = (id: string) => { - const metadata = createMetadata(resumeState()) - const lifecycleSync = options.sync - return createCollection({ - ...options, - id, - sync: { - ...lifecycleSync, - sync: (params: Parameters[0]) => - lifecycleSync.sync({ ...params, metadata: metadata.api }), + oracleReplayTest(it)( + `keeps stream cleanup and stale callbacks scoped to their lifecycle`, + async () => { + const subscribers: Array<(messages: Array>) => void> = + [] + const unsubscribes = [vi.fn(), vi.fn()] + mockSubscribe.mockImplementation((callback) => { + const index = subscribers.length + subscribers.push(callback) + return unsubscribes[index]! + }) + const options = electricCollectionOptions({ + id: `reused-sync-config`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, }, + getKey: (row) => row.id, + startSync: true, }) - } + const createLifecycle = (id: string) => { + const metadata = createMetadata(resumeState()) + const lifecycleSync = options.sync + return createCollection({ + ...options, + id, + sync: { + ...lifecycleSync, + sync: (params: Parameters[0]) => + lifecycleSync.sync({ ...params, metadata: metadata.api }), + }, + }) + } - const oldCollection = createLifecycle(`old-sync-lifecycle`) - const currentCollection = createLifecycle(`current-sync-lifecycle`) - - subscribers[0]!([change(`insert`, 1, `first row`), upToDate]) - subscribers[1]!([change(`insert`, 2, `second row`), upToDate]) - expect(oldCollection.get(1)?.name).toBe(`first row`) - expect(currentCollection.get(2)?.name).toBe(`second row`) - subscribers[0]!([change(`update`, 1, `first row updated`), upToDate]) - const currentMatch = currentCollection.utils.awaitMatch( - (message) => `value` in message && message.value.id === 3, - 100, - ) - const currentTxid = currentCollection.utils.awaitTxId(42, 100) - const currentSnapshotTxid = currentCollection.utils.awaitTxId(50, 100) - let currentMatchResolved = false - let currentTxidResolved = false - let currentSnapshotTxidResolved = false - void currentMatch.then(() => { - currentMatchResolved = true - }) - void currentTxid.then(() => { - currentTxidResolved = true - }) - void currentSnapshotTxid.then(() => { - currentSnapshotTxidResolved = true - }) - subscribers[0]!([ - change(`insert`, 3, `wrong lifecycle`), - { - headers: { - control: `snapshot-end`, - xmin: `100`, - xmax: `150`, - xip_list: [], + const oldCollection = createLifecycle(`old-sync-lifecycle`) + const currentCollection = createLifecycle(`current-sync-lifecycle`) + + subscribers[0]!([change(`insert`, 1, `first row`), upToDate]) + subscribers[1]!([change(`insert`, 2, `second row`), upToDate]) + expect(oldCollection.get(1)?.name).toBe(`first row`) + expect(currentCollection.get(2)?.name).toBe(`second row`) + subscribers[0]!([change(`update`, 1, `first row updated`), upToDate]) + const currentMatch = currentCollection.utils.awaitMatch( + (message) => `value` in message && message.value.id === 3, + 100, + ) + const currentTxid = currentCollection.utils.awaitTxId(42, 100) + const currentSnapshotTxid = currentCollection.utils.awaitTxId(50, 100) + let currentMatchResolved = false + let currentTxidResolved = false + let currentSnapshotTxidResolved = false + void currentMatch.then(() => { + currentMatchResolved = true + }) + void currentTxid.then(() => { + currentTxidResolved = true + }) + void currentSnapshotTxid.then(() => { + currentSnapshotTxidResolved = true + }) + subscribers[0]!([ + change(`insert`, 3, `wrong lifecycle`), + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `150`, + xip_list: [], + }, }, - }, - { headers: { control: `up-to-date`, txids: [42] } }, - ]) - await Promise.resolve() - expect(currentMatchResolved).toBe(false) - expect(currentTxidResolved).toBe(false) - expect(currentSnapshotTxidResolved).toBe(false) + { headers: { control: `up-to-date`, txids: [42] } }, + ]) + await Promise.resolve() + expect(currentMatchResolved).toBe(false) + expect(currentTxidResolved).toBe(false) + expect(currentSnapshotTxidResolved).toBe(false) - await oldCollection.cleanup() + await oldCollection.cleanup() - expect(unsubscribes[0]).toHaveBeenCalledOnce() - expect(unsubscribes[1]).not.toHaveBeenCalled() + expect(unsubscribes[0]).toHaveBeenCalledOnce() + expect(unsubscribes[1]).not.toHaveBeenCalled() - subscribers[0]!([change(`update`, 1, `stale`)]) - expect(unsubscribes[1]).not.toHaveBeenCalled() - subscribers[1]!([ - change(`insert`, 3, `still live`), - { - headers: { - control: `snapshot-end`, - xmin: `100`, - xmax: `150`, - xip_list: [], + subscribers[0]!([change(`update`, 1, `stale`)]) + expect(unsubscribes[1]).not.toHaveBeenCalled() + subscribers[1]!([ + change(`insert`, 3, `still live`), + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `150`, + xip_list: [], + }, }, - }, - { headers: { control: `up-to-date`, txids: [42] } }, - ]) - await expect(currentMatch).resolves.toBe(true) - await expect(currentTxid).resolves.toBe(true) - await expect(currentSnapshotTxid).resolves.toBe(true) - expect(currentCollection.get(3)?.name).toBe(`still live`) - await currentCollection.cleanup() - expect(unsubscribes[1]).toHaveBeenCalledOnce() - }) - - it(`binds sync metadata import and export to the receiving collection`, async () => { - const subscribers: Array<(messages: Array>) => void> = [] - mockSubscribe.mockImplementation((callback) => { - subscribers.push(callback) - return vi.fn() - }) - const options = electricCollectionOptions({ - id: `shared-metadata-config`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: true, - }) - const first = createCollection({ ...options, id: `metadata-first` }) - const second = createCollection({ ...options, id: `metadata-second` }) + { headers: { control: `up-to-date`, txids: [42] } }, + ]) + await expect(currentMatch).resolves.toBe(true) + await expect(currentTxid).resolves.toBe(true) + await expect(currentSnapshotTxid).resolves.toBe(true) + expect(currentCollection.get(3)?.name).toBe(`still live`) + await currentCollection.cleanup() + expect(unsubscribes[1]).toHaveBeenCalledOnce() + }, + ) - subscribers[0]!([{ headers: { control: `up-to-date`, txids: [11] } }]) - subscribers[1]!([{ headers: { control: `up-to-date`, txids: [22] } }]) - - expect(first.config.sync.exportSyncMeta?.()).toMatchObject({ - version: 1, - seenTxids: [11], - }) - expect(second.config.sync.exportSyncMeta?.()).toMatchObject({ - version: 1, - seenTxids: [22], - }) + oracleReplayTest(it)( + `binds sync metadata import and export to the receiving collection`, + async () => { + const subscribers: Array<(messages: Array>) => void> = + [] + mockSubscribe.mockImplementation((callback) => { + subscribers.push(callback) + return vi.fn() + }) + const options = electricCollectionOptions({ + id: `shared-metadata-config`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: true, + }) + const first = createCollection({ ...options, id: `metadata-first` }) + const second = createCollection({ ...options, id: `metadata-second` }) - const importedResume = { - kind: `resume` as const, - requiresTagState: false, - offset: `7_0` as const, - handle: `shape-7`, - shapeId, - updatedAt: 7, - } - first.config.sync.importSyncMeta?.({ - version: 1, - resume: importedResume, - seenTxids: [99], - }) + subscribers[0]!([{ headers: { control: `up-to-date`, txids: [11] } }]) + subscribers[1]!([{ headers: { control: `up-to-date`, txids: [22] } }]) - await expect(first.utils.awaitTxId(99, 50)).resolves.toBe(true) - await expect(second.utils.awaitTxId(99, 10)).rejects.toThrow() - expect(first.config.sync.exportSyncMeta?.()).toEqual({ - version: 1, - resume: importedResume, - seenTxids: [99], - }) - expect(second.config.sync.exportSyncMeta?.()).toMatchObject({ - version: 1, - seenTxids: [22], - }) - expect(second.config.sync.exportSyncMeta?.()).not.toMatchObject({ - resume: importedResume, - }) - - const third = createCollection({ ...options, id: `metadata-third` }) - await expect(third.utils.awaitTxId(99, 10)).rejects.toThrow() - expect(third.config.sync.exportSyncMeta?.()).not.toMatchObject({ - resume: importedResume, - }) + expect(first.config.sync.exportSyncMeta?.()).toMatchObject({ + version: 1, + seenTxids: [11], + }) + expect(second.config.sync.exportSyncMeta?.()).toMatchObject({ + version: 1, + seenTxids: [22], + }) - await first.cleanup() - await second.cleanup() - await third.cleanup() - }) + const importedResume = { + kind: `resume` as const, + requiresTagState: false, + offset: `7_0` as const, + handle: `shape-7`, + shapeId, + updatedAt: 7, + } + first.config.sync.importSyncMeta?.({ + version: 1, + resume: importedResume, + seenTxids: [99], + }) - it(`consumes raw sync metadata when the next collection is materialized`, async () => { - mockSubscribe.mockImplementation(() => vi.fn()) - const options = electricCollectionOptions({ - id: `seeded-metadata-config`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: true, - }) - const importedResume = { - kind: `resume` as const, - requiresTagState: false, - offset: `8_0` as const, - handle: `shape-8`, - shapeId, - updatedAt: 8, - } - options.sync.importSyncMeta?.({ - version: 1, - resume: importedResume, - seenTxids: [55], - }) + await expect(first.utils.awaitTxId(99, 50)).resolves.toBe(true) + await expect(second.utils.awaitTxId(99, 10)).rejects.toThrow() + expect(first.config.sync.exportSyncMeta?.()).toEqual({ + version: 1, + resume: importedResume, + seenTxids: [99], + }) + expect(second.config.sync.exportSyncMeta?.()).toMatchObject({ + version: 1, + seenTxids: [22], + }) + expect(second.config.sync.exportSyncMeta?.()).not.toMatchObject({ + resume: importedResume, + }) - const seeded = createCollection({ ...options, id: `metadata-seeded` }) - await expect(seeded.utils.awaitTxId(55, 50)).resolves.toBe(true) - expect(seeded.config.sync.exportSyncMeta?.()).toMatchObject({ - resume: importedResume, - seenTxids: [55], - }) + const third = createCollection({ ...options, id: `metadata-third` }) + await expect(third.utils.awaitTxId(99, 10)).rejects.toThrow() + expect(third.config.sync.exportSyncMeta?.()).not.toMatchObject({ + resume: importedResume, + }) - const unseeded = createCollection({ ...options, id: `metadata-unseeded` }) - await expect(unseeded.utils.awaitTxId(55, 10)).rejects.toThrow() - expect(unseeded.config.sync.exportSyncMeta?.()).toEqual({ - version: 1, - seenTxids: [], - }) + await first.cleanup() + await second.cleanup() + await third.cleanup() + }, + ) - await seeded.cleanup() - await unseeded.cleanup() - }) + oracleReplayTest(it)( + `consumes raw sync metadata when the next collection is materialized`, + async () => { + mockSubscribe.mockImplementation(() => vi.fn()) + const options = electricCollectionOptions({ + id: `seeded-metadata-config`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: true, + }) + const importedResume = { + kind: `resume` as const, + requiresTagState: false, + offset: `8_0` as const, + handle: `shape-8`, + shapeId, + updatedAt: 8, + } + options.sync.importSyncMeta?.({ + version: 1, + resume: importedResume, + seenTxids: [55], + }) - it(`binds imported evidence and pending matches before lazy sync starts`, async () => { - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementation((callback) => { - subscriber = callback - return vi.fn() - }) - const options = electricCollectionOptions({ - id: `lazy-bound-evidence`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: false, - }) - const collection = createCollection(options) - const importedResume = { - kind: `resume` as const, - requiresTagState: false, - offset: `9_0` as const, - handle: `shape-9`, - shapeId, - updatedAt: 9, - } - collection.config.sync.importSyncMeta?.({ - version: 1, - resume: importedResume, - seenTxids: [33], - }) + const seeded = createCollection({ ...options, id: `metadata-seeded` }) + await expect(seeded.utils.awaitTxId(55, 50)).resolves.toBe(true) + expect(seeded.config.sync.exportSyncMeta?.()).toMatchObject({ + resume: importedResume, + seenTxids: [55], + }) - await expect(collection.utils.awaitTxId(33, 20)).resolves.toBe(true) - const pendingMatch = collection.utils.awaitMatch( - (message) => `value` in message && message.value.id === 5, - 100, - ) - const pendingTxid = collection.utils.awaitTxId(34, 100) - const preload = collection.preload() - expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ - offset: `9_0`, - handle: `shape-9`, - }) - const evidenceChange = change(`insert`, 5, `matched after start`) - subscriber([ - { - ...evidenceChange, - headers: { operation: `insert`, txids: [34] }, - }, - upToDate, - ]) + const unseeded = createCollection({ ...options, id: `metadata-unseeded` }) + await expect(unseeded.utils.awaitTxId(55, 10)).rejects.toThrow() + expect(unseeded.config.sync.exportSyncMeta?.()).toEqual({ + version: 1, + seenTxids: [], + }) - await expect(pendingMatch).resolves.toBe(true) - await expect(pendingTxid).resolves.toBe(true) - await preload - expect(collection.get(5)?.name).toBe(`matched after start`) - await collection.cleanup() - }) + await seeded.cleanup() + await unseeded.cleanup() + }, + ) - it(`keeps descriptor utilities captured before collection startup live`, async () => { - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementation((callback) => { - subscriber = callback - return vi.fn() - }) - const options = electricCollectionOptions({ - id: `captured-descriptor-utilities`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: false, - }) - const { awaitMatch, awaitTxId } = options.utils - const collection = createCollection(options) - const pendingMatch = awaitMatch( - (message) => `value` in message && message.value.id === 77, - 100, - ) - const pendingTxid = awaitTxId(77, 100) + oracleReplayTest(it)( + `binds imported evidence and pending matches before lazy sync starts`, + async () => { + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementation((callback) => { + subscriber = callback + return vi.fn() + }) + const options = electricCollectionOptions({ + id: `lazy-bound-evidence`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: false, + }) + const collection = createCollection(options) + const importedResume = { + kind: `resume` as const, + requiresTagState: false, + offset: `9_0` as const, + handle: `shape-9`, + shapeId, + updatedAt: 9, + } + collection.config.sync.importSyncMeta?.({ + version: 1, + resume: importedResume, + seenTxids: [33], + }) - const preload = collection.preload() - const evidenceChange = change(`insert`, 77, `captured utilities`) - subscriber([ - { - ...evidenceChange, - headers: { operation: `insert`, txids: [77] }, - }, - upToDate, - ]) + await expect(collection.utils.awaitTxId(33, 20)).resolves.toBe(true) + const pendingMatch = collection.utils.awaitMatch( + (message) => `value` in message && message.value.id === 5, + 100, + ) + const pendingTxid = collection.utils.awaitTxId(34, 100) + const preload = collection.preload() + expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ + offset: `9_0`, + handle: `shape-9`, + }) + const evidenceChange = change(`insert`, 5, `matched after start`) + subscriber([ + { + ...evidenceChange, + headers: { operation: `insert`, txids: [34] }, + }, + upToDate, + ]) - await expect(pendingMatch).resolves.toBe(true) - await expect(pendingTxid).resolves.toBe(true) - await preload - await collection.cleanup() - }) + await expect(pendingMatch).resolves.toBe(true) + await expect(pendingTxid).resolves.toBe(true) + await preload + expect(collection.get(5)?.name).toBe(`matched after start`) + await collection.cleanup() + }, + ) - it(`retires a pending pre-start match when a lazy collection is cleaned up`, async () => { - mockSubscribe.mockImplementation(() => vi.fn()) - const collection = createCollection( - electricCollectionOptions({ - id: `lazy-pre-start-cleanup`, + oracleReplayTest(it)( + `keeps descriptor utilities captured before collection startup live`, + async () => { + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementation((callback) => { + subscriber = callback + return vi.fn() + }) + const options = electricCollectionOptions({ + id: `captured-descriptor-utilities`, shapeOptions: { url: `http://test-url`, params: { table: `test_table` }, }, getKey: (row) => row.id, startSync: false, - }), - ) - const pendingMatch = collection.utils.awaitMatch(() => false, 100) + }) + const { awaitMatch, awaitTxId } = options.utils + const collection = createCollection(options) + const pendingMatch = awaitMatch( + (message) => `value` in message && message.value.id === 77, + 100, + ) + const pendingTxid = awaitTxId(77, 100) - await collection.cleanup() + const preload = collection.preload() + const evidenceChange = change(`insert`, 77, `captured utilities`) + subscriber([ + { + ...evidenceChange, + headers: { operation: `insert`, txids: [77] }, + }, + upToDate, + ]) - await expect(pendingMatch).rejects.toThrow(/aborted/i) - expect(mockSubscribe).not.toHaveBeenCalled() - }) + await expect(pendingMatch).resolves.toBe(true) + await expect(pendingTxid).resolves.toBe(true) + await preload + await collection.cleanup() + }, + ) - it(`retires pre-start waiters when persisted metadata startup is interrupted`, async () => { - const metadataStarted = createDeferred() - const metadataGate = createDeferred() - const adapter = createPersistedAdapter(new Map(), new Map()) - const loadResumeSnapshot = adapter.loadResumeSnapshot - adapter.loadResumeSnapshot = async (...args) => { - metadataStarted.resolve() - await metadataGate.promise - return loadResumeSnapshot(args[0], args[1]) - } - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricCollectionOptions({ - id: `persisted-startup-waiter-cleanup`, + oracleReplayTest(it)( + `retires a pending pre-start match when a lazy collection is cleaned up`, + async () => { + mockSubscribe.mockImplementation(() => vi.fn()) + const collection = createCollection( + electricCollectionOptions({ + id: `lazy-pre-start-cleanup`, shapeOptions: { url: `http://test-url`, params: { table: `test_table` }, @@ -4675,230 +4605,291 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { getKey: (row) => row.id, startSync: false, }), - persistence: { adapter }, - }), - ) - const pendingMatch = collection.utils.awaitMatch(() => false, 100) - const pendingTxid = collection.utils.awaitTxId(702, 100) - const preload = expect(collection.preload()).rejects.toMatchObject({ - name: `AbortError`, - }) - await metadataStarted.promise - const matchOutcome = expect(pendingMatch).rejects.toThrow(/aborted/i) - const txidOutcome = expect(pendingTxid).rejects.toThrow(/aborted/i) + ) + const pendingMatch = collection.utils.awaitMatch(() => false, 100) - await collection.cleanup() + await collection.cleanup() - await matchOutcome - await txidOutcome - expect(mockSubscribe).not.toHaveBeenCalled() - metadataGate.resolve() - await preload - }) + await expect(pendingMatch).rejects.toThrow(/aborted/i) + expect(mockSubscribe).not.toHaveBeenCalled() + }, + ) - it(`retires pre-start waiters through automatic collection GC`, async () => { - const metadataGate = createDeferred() - const adapter = createPersistedAdapter(new Map(), new Map()) - const loadResumeSnapshot = adapter.loadResumeSnapshot - adapter.loadResumeSnapshot = vi.fn(async (...args) => { - await metadataGate.promise - return loadResumeSnapshot(args[0], args[1]) - }) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricCollectionOptions({ - id: `persisted-gc-waiter-cleanup`, + oracleReplayTest(it)( + `retires pre-start waiters when persisted metadata startup is interrupted`, + async () => { + const metadataStarted = createDeferred() + const metadataGate = createDeferred() + const adapter = createPersistedAdapter(new Map(), new Map()) + const loadResumeSnapshot = adapter.loadResumeSnapshot + adapter.loadResumeSnapshot = async (...args) => { + metadataStarted.resolve() + await metadataGate.promise + return loadResumeSnapshot(args[0], args[1]) + } + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `persisted-startup-waiter-cleanup`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: false, + }), + persistence: { adapter }, + }), + ) + const pendingMatch = collection.utils.awaitMatch(() => false, 100) + const pendingTxid = collection.utils.awaitTxId(702, 100) + const preload = expect(collection.preload()).rejects.toMatchObject({ + name: `AbortError`, + }) + await metadataStarted.promise + const matchOutcome = expect(pendingMatch).rejects.toThrow(/aborted/i) + const txidOutcome = expect(pendingTxid).rejects.toThrow(/aborted/i) + + await collection.cleanup() + + await matchOutcome + await txidOutcome + expect(mockSubscribe).not.toHaveBeenCalled() + metadataGate.resolve() + await preload + }, + ) + + oracleReplayTest(it)( + `retires pre-start waiters through automatic collection GC`, + async () => { + const metadataGate = createDeferred() + const adapter = createPersistedAdapter(new Map(), new Map()) + const loadResumeSnapshot = adapter.loadResumeSnapshot + adapter.loadResumeSnapshot = vi.fn(async (...args) => { + await metadataGate.promise + return loadResumeSnapshot(args[0], args[1]) + }) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `persisted-gc-waiter-cleanup`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: false, + gcTime: 10, + }), + persistence: { adapter }, + }), + ) + const pendingMatch = collection.utils.awaitMatch(() => false, 5000) + const pendingTxid = collection.utils.awaitTxId(703, 5000) + // A pending preload owns retention; exercise unowned sync for automatic GC. + collection.startSyncImmediate() + await vi.waitFor( + () => expect(adapter.loadResumeSnapshot).toHaveBeenCalledOnce(), + { interval: 1, timeout: 250 }, + ) + const subscription = collection.subscribeChanges(() => {}) + const matchOutcome = pendingMatch.catch((error: unknown) => error) + const txidOutcome = pendingTxid.catch((error: unknown) => error) + + subscription.unsubscribe() + await vi.waitFor(() => expect(collection.status).toBe(`cleaned-up`), { + interval: 10, + timeout: 1500, + }) + const pendingSentinel = Symbol(`pending`) + const matchResult = await Promise.race([ + matchOutcome, + new Promise((resolve) => + setTimeout(() => resolve(pendingSentinel), 50), + ), + ]) + const txidResult = await Promise.race([ + txidOutcome, + new Promise((resolve) => + setTimeout(() => resolve(pendingSentinel), 50), + ), + ]) + + expect(matchResult).toEqual( + expect.objectContaining({ + message: expect.stringMatching(/aborted/i), + }), + ) + expect(txidResult).toEqual( + expect.objectContaining({ + message: expect.stringMatching(/aborted/i), + }), + ) + expect(mockSubscribe).not.toHaveBeenCalled() + metadataGate.resolve() + }, + ) + + oracleReplayTest(it)( + `retires every pending waiter when its collection lifecycle is cleaned up`, + async () => { + mockSubscribe.mockImplementation(() => vi.fn()) + const lazy = createCollection( + electricCollectionOptions({ + id: `lazy-waiter-cleanup`, shapeOptions: { url: `http://test-url`, params: { table: `test_table` }, }, getKey: (row) => row.id, startSync: false, - gcTime: 10, }), - persistence: { adapter }, - }), - ) - const pendingMatch = collection.utils.awaitMatch(() => false, 5000) - const pendingTxid = collection.utils.awaitTxId(703, 5000) - // A pending preload owns retention; exercise unowned sync for automatic GC. - collection.startSyncImmediate() - await vi.waitFor( - () => expect(adapter.loadResumeSnapshot).toHaveBeenCalledOnce(), - { interval: 1, timeout: 250 }, - ) - const subscription = collection.subscribeChanges(() => {}) - const matchOutcome = pendingMatch.catch((error: unknown) => error) - const txidOutcome = pendingTxid.catch((error: unknown) => error) - - subscription.unsubscribe() - await vi.waitFor(() => expect(collection.status).toBe(`cleaned-up`), { - interval: 10, - timeout: 1500, - }) - const pendingSentinel = Symbol(`pending`) - const matchResult = await Promise.race([ - matchOutcome, - new Promise((resolve) => setTimeout(() => resolve(pendingSentinel), 50)), - ]) - const txidResult = await Promise.race([ - txidOutcome, - new Promise((resolve) => setTimeout(() => resolve(pendingSentinel), 50)), - ]) + ) + const lazyTxid = lazy.utils.awaitTxId(700, 100) + await lazy.cleanup() + await expect(lazyTxid).rejects.toThrow(/aborted/i) - expect(matchResult).toEqual( - expect.objectContaining({ - message: expect.stringMatching(/aborted/i), - }), - ) - expect(txidResult).toEqual( - expect.objectContaining({ - message: expect.stringMatching(/aborted/i), - }), - ) - expect(mockSubscribe).not.toHaveBeenCalled() - metadataGate.resolve() - }) + const active = createOracleCollection( + `active-waiter-cleanup`, + `eager`, + createMetadata(new Map()).api, + ) + const activeMatch = active.collection.utils.awaitMatch(() => false, 100) + const activeTxid = active.collection.utils.awaitTxId(701, 100) + await active.collection.cleanup() - it(`retires every pending waiter when its collection lifecycle is cleaned up`, async () => { - mockSubscribe.mockImplementation(() => vi.fn()) - const lazy = createCollection( - electricCollectionOptions({ - id: `lazy-waiter-cleanup`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: false, - }), - ) - const lazyTxid = lazy.utils.awaitTxId(700, 100) - await lazy.cleanup() - await expect(lazyTxid).rejects.toThrow(/aborted/i) + await expect(activeMatch).rejects.toThrow(/aborted/i) + await expect(activeTxid).rejects.toThrow(/aborted/i) + }, + ) - const active = createOracleCollection( - `active-waiter-cleanup`, - `eager`, - createMetadata(new Map()).api, - ) - const activeMatch = active.collection.utils.awaitMatch(() => false, 100) - const activeTxid = active.collection.utils.awaitTxId(701, 100) - await active.collection.cleanup() + oracleReplayTest(it)( + `settles waiters according to whether evidence or cleanup wins`, + async () => { + for (const cleanupWins of [true, false]) { + const trace = createOracleCollection( + `waiter-race-${cleanupWins}`, + `eager`, + createMetadata(new Map()).api, + ) + const pendingMatch = trace.collection.utils.awaitMatch( + (message) => `value` in message && message.value.id === 91, + 100, + ) + const pendingTxid = trace.collection.utils.awaitTxId(91, 100) + const evidenceChange = change(`insert`, 91, `race winner`) + const evidence: Array> = [ + { + ...evidenceChange, + headers: { operation: `insert`, txids: [91] }, + }, + upToDate, + ] - await expect(activeMatch).rejects.toThrow(/aborted/i) - await expect(activeTxid).rejects.toThrow(/aborted/i) - }) + if (cleanupWins) { + await trace.collection.cleanup() + trace.subscriber(evidence) + await expect(pendingMatch).rejects.toThrow(/aborted/i) + await expect(pendingTxid).rejects.toThrow(/aborted/i) + expect(trace.collection.get(91)).toBeUndefined() + } else { + trace.subscriber(evidence) + await trace.collection.cleanup() + await expect(pendingMatch).resolves.toBe(true) + await expect(pendingTxid).resolves.toBe(true) + } + } + }, + ) - it(`settles waiters according to whether evidence or cleanup wins`, async () => { - for (const cleanupWins of [true, false]) { + oracleReplayTest(it)( + `keeps committed match evidence across newer writer batches`, + async () => { + const metadata = createMetadata(new Map()) const trace = createOracleCollection( - `waiter-race-${cleanupWins}`, + `await-match-batch-generation`, `eager`, - createMetadata(new Map()).api, - ) - const pendingMatch = trace.collection.utils.awaitMatch( - (message) => `value` in message && message.value.id === 91, - 100, + metadata.api, ) - const pendingTxid = trace.collection.utils.awaitTxId(91, 100) - const evidenceChange = change(`insert`, 91, `race winner`) - const evidence: Array> = [ - { - ...evidenceChange, - headers: { operation: `insert`, txids: [91] }, - }, - upToDate, - ] - - if (cleanupWins) { - await trace.collection.cleanup() - trace.subscriber(evidence) - await expect(pendingMatch).rejects.toThrow(/aborted/i) - await expect(pendingTxid).rejects.toThrow(/aborted/i) - expect(trace.collection.get(91)).toBeUndefined() - } else { - trace.subscriber(evidence) - await trace.collection.cleanup() - await expect(pendingMatch).resolves.toBe(true) - await expect(pendingTxid).resolves.toBe(true) - } - } - }) - - it(`keeps committed match evidence across newer writer batches`, async () => { - const metadata = createMetadata(new Map()) - const trace = createOracleCollection( - `await-match-batch-generation`, - `eager`, - metadata.api, - ) - trace.subscriber([change(`insert`, 1, `old batch`), upToDate]) - trace.subscriber([change(`insert`, 2, `current batch`), upToDate]) + trace.subscriber([change(`insert`, 1, `old batch`), upToDate]) + trace.subscriber([change(`insert`, 2, `current batch`), upToDate]) - await expect( - trace.collection.utils.awaitMatch( - (message) => `value` in message && message.value.name === `old batch`, - 20, - ), - ).resolves.toBe(true) - await trace.collection.cleanup() - }) + await expect( + trace.collection.utils.awaitMatch( + (message) => `value` in message && message.value.name === `old batch`, + 20, + ), + ).resolves.toBe(true) + await trace.collection.cleanup() + }, + ) - it(`keeps committed match evidence across callbacks with no new data`, async () => { - const trace = createOracleCollection( - `await-match-neutral-callback`, - `eager`, - createMetadata(new Map()).api, - ) + oracleReplayTest(it)( + `keeps committed match evidence across callbacks with no new data`, + async () => { + const trace = createOracleCollection( + `await-match-neutral-callback`, + `eager`, + createMetadata(new Map()).api, + ) - trace.subscriber([change(`insert`, 1, `committed`), upToDate]) - trace.subscriber([ - { - headers: { - control: `snapshot-end`, - xmin: `100`, - xmax: `150`, - xip_list: [], + trace.subscriber([change(`insert`, 1, `committed`), upToDate]) + trace.subscriber([ + { + headers: { + control: `snapshot-end`, + xmin: `100`, + xmax: `150`, + xip_list: [], + }, }, - }, - ]) - trace.subscriber([]) + ]) + trace.subscriber([]) - await expect( - trace.collection.utils.awaitMatch( - (message) => `value` in message && message.value.id === 1, - 20, - ), - ).resolves.toBe(true) - await trace.collection.cleanup() - }) + await expect( + trace.collection.utils.awaitMatch( + (message) => `value` in message && message.value.id === 1, + 20, + ), + ).resolves.toBe(true) + await trace.collection.cleanup() + }, + ) - it(`clears committed match evidence when the stream must refetch`, async () => { - const trace = createOracleCollection( - `await-match-reset-generation`, - `eager`, - createMetadata(new Map()).api, - ) + oracleReplayTest(it)( + `clears committed match evidence when the stream must refetch`, + async () => { + const trace = createOracleCollection( + `await-match-reset-generation`, + `eager`, + createMetadata(new Map()).api, + ) - trace.subscriber([change(`insert`, 1, `old generation`), upToDate]) - trace.subscriber([mustRefetch, upToDate]) + trace.subscriber([change(`insert`, 1, `old generation`), upToDate]) + trace.subscriber([mustRefetch, upToDate]) - await expect( - trace.collection.utils.awaitMatch( - (message) => - `value` in message && message.value.name === `old generation`, - 20, - ), - ).rejects.toThrow(/Timeout waiting for custom match function/) - await trace.collection.cleanup() - }) + await expect( + trace.collection.utils.awaitMatch( + (message) => + `value` in message && message.value.name === `old generation`, + 20, + ), + ).rejects.toThrow(/Timeout waiting for custom match function/) + await trace.collection.cleanup() + }, + ) const reentryHistory = fc.record({ txid: fc.integer({ min: 1, max: 10000 }), @@ -5001,11 +4992,14 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { } } - fcTest.prop([reentryHistory], { seed: 42713, numRuns: oracleRuns(10) })( + oracleReplayTest(fcTest).prop([reentryHistory], { + seed: 42713, + numRuns: oracleRuns(10), + })( `replacement sync runs reject evidence from callback reentry histories (fixed)`, runReentryHistory, ) - fcTest.prop( + oracleReplayTest(fcTest, `electric.match-reentry`).prop( [reentryHistory], oraclePropertyOptions(10, `electric.match-reentry`), )( @@ -5013,38 +5007,42 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { runReentryHistory, ) - it(`does not let a committed message satisfy awaitMatch after restart`, async () => { - const subscribers: Array<(messages: Array>) => void> = [] - mockSubscribe.mockImplementation((callback) => { - subscribers.push(callback) - return vi.fn() - }) - const collection = createCollection( - electricCollectionOptions({ - id: `await-match-lifecycle-generation`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: true, - }), - ) + oracleReplayTest(it)( + `does not let a committed message satisfy awaitMatch after restart`, + async () => { + const subscribers: Array<(messages: Array>) => void> = + [] + mockSubscribe.mockImplementation((callback) => { + subscribers.push(callback) + return vi.fn() + }) + const collection = createCollection( + electricCollectionOptions({ + id: `await-match-lifecycle-generation`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: true, + }), + ) - subscribers[0]!([change(`insert`, 1, `old lifecycle`), upToDate]) - await collection.cleanup() - collection.startSyncImmediate() - await vi.waitFor(() => expect(subscribers).toHaveLength(2)) + subscribers[0]!([change(`insert`, 1, `old lifecycle`), upToDate]) + await collection.cleanup() + collection.startSyncImmediate() + await vi.waitFor(() => expect(subscribers).toHaveLength(2)) - await expect( - collection.utils.awaitMatch( - (message) => - `value` in message && message.value.name === `old lifecycle`, - 20, - ), - ).rejects.toThrow(/Timeout waiting for custom match function/) - await collection.cleanup() - }) + await expect( + collection.utils.awaitMatch( + (message) => + `value` in message && message.value.name === `old lifecycle`, + 20, + ), + ).rejects.toThrow(/Timeout waiting for custom match function/) + await collection.cleanup() + }, + ) for (const syncMode of [`eager`, `on-demand`, `progressive`] as const) { it(`does not apply an unseen ${syncMode} update after must-refetch`, async () => { @@ -5102,80 +5100,86 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }) } - it(`does not let an older applied receipt finish a newer reset`, async () => { - const metadata = createMetadata(resumeState()) - const trace = createOracleCollection( - `overlapping-reset-generations`, - `eager`, - metadata.api, - ) - const persistence = createDeferred() - const transaction = createTransaction({ - mutationFn: () => persistence.promise, - }) + oracleReplayTest(it)( + `does not let an older applied receipt finish a newer reset`, + async () => { + const metadata = createMetadata(resumeState()) + const trace = createOracleCollection( + `overlapping-reset-generations`, + `eager`, + metadata.api, + ) + const persistence = createDeferred() + const transaction = createTransaction({ + mutationFn: () => persistence.promise, + }) - trace.subscriber([ - mustRefetch, - change(`insert`, 1, `first replacement`), - subsetEnd, - ]) - transaction.mutate(() => - trace.collection.insert({ - id: 99, - name: `optimistic`, - stable: `stable-99`, - }), - ) - trace.subscriber([upToDate]) - trace.subscriber([mustRefetch]) - await Promise.resolve() + trace.subscriber([ + mustRefetch, + change(`insert`, 1, `first replacement`), + subsetEnd, + ]) + transaction.mutate(() => + trace.collection.insert({ + id: 99, + name: `optimistic`, + stable: `stable-99`, + }), + ) + trace.subscriber([upToDate]) + trace.subscriber([mustRefetch]) + await Promise.resolve() - trace.subscriber([change(`update`, 2, `unseen`), upToDate]) + trace.subscriber([change(`update`, 2, `unseen`), upToDate]) - expect(trace.collection.status).not.toBe(`error`) - expect(trace.collection.has(2)).toBe(false) + expect(trace.collection.status).not.toBe(`error`) + expect(trace.collection.has(2)).toBe(false) - persistence.resolve() - await transaction.isPersisted.promise - await trace.collection.cleanup() - }) + persistence.resolve() + await transaction.isPersisted.promise + await trace.collection.cleanup() + }, + ) // Readiness counts accepted rows; they publish when the mutation settles. - it(`publishes readiness once a held receipt is accepted`, async () => { - const metadata = createMetadata(new Map()) - const trace = createOracleCollection( - `deferred-applied-receipt`, - `eager`, - metadata.api, - ) - const persistence = createDeferred() - const transaction = createTransaction({ - mutationFn: () => persistence.promise, - }) - transaction.mutate(() => - trace.collection.insert({ - id: 99, - name: `optimistic`, - stable: `stable-99`, - }), - ) + oracleReplayTest(it)( + `publishes readiness once a held receipt is accepted`, + async () => { + const metadata = createMetadata(new Map()) + const trace = createOracleCollection( + `deferred-applied-receipt`, + `eager`, + metadata.api, + ) + const persistence = createDeferred() + const transaction = createTransaction({ + mutationFn: () => persistence.promise, + }) + transaction.mutate(() => + trace.collection.insert({ + id: 99, + name: `optimistic`, + stable: `stable-99`, + }), + ) - trace.subscriber([change(`insert`, 1, `synced`), upToDate]) - await Promise.resolve() + trace.subscriber([change(`insert`, 1, `synced`), upToDate]) + await Promise.resolve() - expect(trace.collection.status).toBe(`ready`) - expect(trace.collection.has(1)).toBe(false) + expect(trace.collection.status).toBe(`ready`) + expect(trace.collection.has(1)).toBe(false) - persistence.resolve() - await transaction.isPersisted.promise - await trace.collection.stateWhenReady() + persistence.resolve() + await transaction.isPersisted.promise + await trace.collection.stateWhenReady() - expect(trace.collection.status).toBe(`ready`) - expect(trace.collection.get(1)).toEqual( - expect.objectContaining({ stable: `stable-1` }), - ) - await trace.collection.cleanup() - }) + expect(trace.collection.status).toBe(`ready`) + expect(trace.collection.get(1)).toEqual( + expect.objectContaining({ stable: `stable-1` }), + ) + await trace.collection.cleanup() + }, + ) /** * Full-mode recovery can accept its complete source snapshot while an @@ -5189,7 +5193,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { * before the acquisition's own commit cursor. The transaction promise * controls application; yielding a turn only drains already queued work. */ - it.each([`before-ready`, `after-ready`] as const)( + oracleReplayTest(it).each([`before-ready`, `after-ready`] as const)( `full recovery subset waits for application when acquired %s`, async (acquireAt) => { const trace = createOracleCollection( @@ -5249,43 +5253,46 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { // A full stream keeps its capability across a same-run must-refetch. Its // previous applied snapshot cannot establish newly requested replacement // coverage. This continuation rejects a permanently fulfilled startup gate. - it(`full recovery demand waits again after a same-run reset`, async () => { - const trace = createOracleCollection( - `full-recovery-reset`, - `on-demand`, - createMetadata( - new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), - ).api, - ) - await withElectricCleanup(async () => { - trace.subscriber([change(`insert`, 1, `first`), upToDate]) - await trace.collection._sync.loadSubset({ limit: 1 }) - trace.subscriber([mustRefetch]) - let outcome = `pending` - const loading = Promise.resolve( - trace.collection._sync.loadSubset({ limit: 2 }), - ).then( - () => { - outcome = `fulfilled` - }, - (error: unknown) => { - outcome = `rejected` - throw error - }, - ) - void loading.catch(() => undefined) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(outcome, `prior readiness is not replacement coverage`).toBe( - `pending`, + oracleReplayTest(it)( + `full recovery demand waits again after a same-run reset`, + async () => { + const trace = createOracleCollection( + `full-recovery-reset`, + `on-demand`, + createMetadata( + new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), + ).api, ) - trace.subscriber([change(`insert`, 2, `second`), upToDate]) - await atCheckpoint(loading, `same-run replacement applied`) - expect(outcome).toBe(`fulfilled`) - expect(rowsFromCollection(trace.collection)).toEqual([ - [2, `second`, `stable-2`], - ]) - }, [() => trace.collection.cleanup()]) - }) + await withElectricCleanup(async () => { + trace.subscriber([change(`insert`, 1, `first`), upToDate]) + await trace.collection._sync.loadSubset({ limit: 1 }) + trace.subscriber([mustRefetch]) + let outcome = `pending` + const loading = Promise.resolve( + trace.collection._sync.loadSubset({ limit: 2 }), + ).then( + () => { + outcome = `fulfilled` + }, + (error: unknown) => { + outcome = `rejected` + throw error + }, + ) + void loading.catch(() => undefined) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(outcome, `prior readiness is not replacement coverage`).toBe( + `pending`, + ) + trace.subscriber([change(`insert`, 2, `second`), upToDate]) + await atCheckpoint(loading, `same-run replacement applied`) + expect(outcome).toBe(`fulfilled`) + expect(rowsFromCollection(trace.collection)).toEqual([ + [2, `second`, `stable-2`], + ]) + }, [() => trace.collection.cleanup()]) + }, + ) /** * ## Does a source that still tries ever look failed? @@ -5901,76 +5908,82 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { * public visibility at demand settlement. Full-mode replay's distinct * up-to-date obligation is checked by neighboring laws. */ - it(`applies a scoped reset subset before its demand succeeds`, async () => { - let subscriber: ((messages: Array>) => void) | undefined - mockSubscribe.mockImplementation((callback) => { - subscriber = callback - return vi.fn() - }) - const adapter = createPersistedAdapter( - new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), - new Map(), - ) - // This fixture abstracts physical cache IDs because the law here concerns - // source settlement. The SQLite generation oracle owns physical isolation. - let generation = 0 - const claim = (id: string) => ({ - storageCollectionId: `${id}:generation-${generation}`, - claimId: `claim-${generation}`, - }) - adapter.claimCacheGeneration = async (id) => claim(id) - adapter.rotateCacheGeneration = async (id) => { - generation++ - return claim(id) - } - adapter.renewCacheGenerationClaim = async () => Date.now() + 60_000 - adapter.releaseCacheGenerationClaim = async () => {} - const options = electricCollectionOptions({ - id: `scoped-reset-applied-settlement`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - syncMode: `on-demand`, - getKey: (row) => row.id, - startSync: true, - }) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ ...options, persistence: { adapter } }), - ) - const snapshot = createDeferred() - mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) - - await withElectricCleanup(async () => { - collection.startSyncImmediate() - await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) - const demand = Promise.resolve(collection._sync.loadSubset({ limit: 1 })) - void demand.catch(() => undefined) - await vi.waitFor(() => - expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + oracleReplayTest(it)( + `applies a scoped reset subset before its demand succeeds`, + async () => { + let subscriber: + ((messages: Array>) => void) | undefined + mockSubscribe.mockImplementation((callback) => { + subscriber = callback + return vi.fn() + }) + const adapter = createPersistedAdapter( + new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), + new Map(), ) - subscriber!([mustRefetch]) - subscriber!([change(`insert`, 1, `scoped replacement`), subsetEnd]) - snapshot.resolve() - await atCheckpoint(demand, `scoped reset applied settlement`) - expect(collection.get(1)).toMatchObject({ - id: 1, - name: `scoped replacement`, - stable: `stable-1`, + // This fixture abstracts physical cache IDs because the law here concerns + // source settlement. The SQLite generation oracle owns physical isolation. + let generation = 0 + const claim = (id: string) => ({ + storageCollectionId: `${id}:generation-${generation}`, + claimId: `claim-${generation}`, }) - }, [() => snapshot.resolve(), () => collection.cleanup()]) - }) + adapter.claimCacheGeneration = async (id) => claim(id) + adapter.rotateCacheGeneration = async (id) => { + generation++ + return claim(id) + } + adapter.renewCacheGenerationClaim = async () => Date.now() + 60_000 + adapter.releaseCacheGenerationClaim = async () => {} + const options = electricCollectionOptions({ + id: `scoped-reset-applied-settlement`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `on-demand`, + getKey: (row) => row.id, + startSync: true, + }) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ ...options, persistence: { adapter } }), + ) + const snapshot = createDeferred() + mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) + + await withElectricCleanup(async () => { + collection.startSyncImmediate() + await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) + const demand = Promise.resolve( + collection._sync.loadSubset({ limit: 1 }), + ) + void demand.catch(() => undefined) + await vi.waitFor(() => + expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + ) + subscriber!([mustRefetch]) + subscriber!([change(`insert`, 1, `scoped replacement`), subsetEnd]) + snapshot.resolve() + await atCheckpoint(demand, `scoped reset applied settlement`) + expect(collection.get(1)).toMatchObject({ + id: 1, + name: `scoped replacement`, + stable: `stable-1`, + }) + }, [() => snapshot.resolve(), () => collection.cleanup()]) + }, + ) // Initial failure rejects all current waiters with the original error. // Cleanup quietly settles adapter loads and invalidates later callbacks. // A provider error alone does not make its future callbacks obsolete; // retry semantics are checked by the neighboring recovery laws. - it.each([`failure`, `cleanup`] as const)( + oracleReplayTest(it).each([`failure`, `cleanup`] as const)( `full recovery retires concurrent demand on %s`, async (terminal) => { const loggedError = vi @@ -6027,264 +6040,279 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { // shared. Aborting one demand ends that load before up-to-date; its sibling // and a replacement of the canceled demand still wait for the source // snapshot and succeed after the row applies. - it(`cancels one full recovery demand without settling its sibling`, async () => { - const trace = createOracleCollection( - `full-recovery-one-abort`, - `on-demand`, - createMetadata( - new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), - ).api, - ) - const controller = new AbortController() - const canceled = Promise.resolve( - trace.collection._sync.loadSubset({ - limit: 1, - signal: controller.signal, - }), - ) - void canceled.catch(() => undefined) - let siblingOutcome = `pending` - const sibling = Promise.resolve( - trace.collection._sync.loadSubset({ limit: 2 }), - ).then( - () => { - siblingOutcome = `fulfilled` - }, - () => { - siblingOutcome = `rejected` - }, - ) - - await withElectricCleanup(async () => { - controller.abort() - await expect( - atCheckpoint(canceled, `canceled full recovery demand`), - ).rejects.toMatchObject({ name: `AbortError` }) - expect(siblingOutcome).toBe(`pending`) - - // Cancellation cannot mark this predicate complete or poison a later - // acquisition of the same demand key. - const replacement = Promise.resolve( - trace.collection._sync.loadSubset({ limit: 1 }), + oracleReplayTest(it)( + `cancels one full recovery demand without settling its sibling`, + async () => { + const trace = createOracleCollection( + `full-recovery-one-abort`, + `on-demand`, + createMetadata( + new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), + ).api, + ) + const controller = new AbortController() + const canceled = Promise.resolve( + trace.collection._sync.loadSubset({ + limit: 1, + signal: controller.signal, + }), ) - let replacementOutcome = `pending` - const observedReplacement = replacement.then( + void canceled.catch(() => undefined) + let siblingOutcome = `pending` + const sibling = Promise.resolve( + trace.collection._sync.loadSubset({ limit: 2 }), + ).then( () => { - replacementOutcome = `fulfilled` + siblingOutcome = `fulfilled` }, () => { - replacementOutcome = `rejected` + siblingOutcome = `rejected` }, ) - await Promise.resolve() - expect(replacementOutcome).toBe(`pending`) - trace.subscriber([change(`insert`, 1, `source row`), upToDate]) - await atCheckpoint( - Promise.all([sibling, observedReplacement]), - `remaining full recovery demands`, - ) - expect(siblingOutcome).toBe(`fulfilled`) - expect(replacementOutcome).toBe(`fulfilled`) - expect(trace.collection.get(1)).toMatchObject({ name: `source row` }) - }, [() => trace.collection.cleanup()]) - }) + await withElectricCleanup(async () => { + controller.abort() + await expect( + atCheckpoint(canceled, `canceled full recovery demand`), + ).rejects.toMatchObject({ name: `AbortError` }) + expect(siblingOutcome).toBe(`pending`) + + // Cancellation cannot mark this predicate complete or poison a later + // acquisition of the same demand key. + const replacement = Promise.resolve( + trace.collection._sync.loadSubset({ limit: 1 }), + ) + let replacementOutcome = `pending` + const observedReplacement = replacement.then( + () => { + replacementOutcome = `fulfilled` + }, + () => { + replacementOutcome = `rejected` + }, + ) + await Promise.resolve() + expect(replacementOutcome).toBe(`pending`) + + trace.subscriber([change(`insert`, 1, `source row`), upToDate]) + await atCheckpoint( + Promise.all([sibling, observedReplacement]), + `remaining full recovery demands`, + ) + expect(siblingOutcome).toBe(`fulfilled`) + expect(replacementOutcome).toBe(`fulfilled`) + expect(trace.collection.get(1)).toMatchObject({ name: `source row` }) + }, [() => trace.collection.cleanup()]) + }, + ) // An external ShapeStream abort is a provider failure, not Collection // cleanup. It cannot fulfill an in-flight demand or certify its key as // loaded when no source snapshot arrived. A second identical demand must // therefore remain a failing acquisition rather than deduplicated success. - it(`does not certify a full recovery demand after external stream abort`, async () => { - const external = new AbortController() - const trace = createOracleCollection( - `full-recovery-external-abort`, - `on-demand`, - createMetadata( - new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), - ).api, - { signal: external.signal }, - ) - const first = Promise.resolve( - trace.collection._sync.loadSubset({ limit: 1 }), - ) - void first.catch(() => undefined) + oracleReplayTest(it)( + `does not certify a full recovery demand after external stream abort`, + async () => { + const external = new AbortController() + const trace = createOracleCollection( + `full-recovery-external-abort`, + `on-demand`, + createMetadata( + new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), + ).api, + { signal: external.signal }, + ) + const first = Promise.resolve( + trace.collection._sync.loadSubset({ limit: 1 }), + ) + void first.catch(() => undefined) - await withElectricCleanup(async () => { - external.abort() - await expect( - atCheckpoint(first, `externally aborted full recovery demand`), - ).rejects.toMatchObject({ name: `StreamAbortedError` }) - const second = trace.collection._sync.loadSubset({ limit: 1 }) - expect(second).not.toBe(true) - await expect(Promise.resolve(second)).rejects.toMatchObject({ - name: `AbortError`, - }) - expect(trace.collection.size).toBe(0) - }, [() => trace.collection.cleanup()]) - }) + await withElectricCleanup(async () => { + external.abort() + await expect( + atCheckpoint(first, `externally aborted full recovery demand`), + ).rejects.toMatchObject({ name: `StreamAbortedError` }) + const second = trace.collection._sync.loadSubset({ limit: 1 }) + expect(second).not.toBe(true) + await expect(Promise.resolve(second)).rejects.toMatchObject({ + name: `AbortError`, + }) + expect(trace.collection.size).toBe(0) + }, [() => trace.collection.cleanup()]) + }, + ) // A subset transport may return after the shared stream retires. Its // messages cannot apply to that lifecycle, so transport settlement alone // cannot certify the demand or its deduplication key. - it(`does not certify a subset snapshot after external stream abort`, async () => { - const external = new AbortController() - const trace = createOracleCollection( - `subset-external-abort`, - `on-demand`, - createMetadata(new Map()).api, - { signal: external.signal }, - ) - const snapshot = createDeferred() - mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) - const first = Promise.resolve( - trace.collection._sync.loadSubset({ limit: 1 }), - ) - void first.catch(() => undefined) - - await withElectricCleanup(async () => { - await vi.waitFor(() => - expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + oracleReplayTest(it)( + `does not certify a subset snapshot after external stream abort`, + async () => { + const external = new AbortController() + const trace = createOracleCollection( + `subset-external-abort`, + `on-demand`, + createMetadata(new Map()).api, + { signal: external.signal }, ) - external.abort() - snapshot.resolve() - await expect( - atCheckpoint(first, `externally aborted subset snapshot`), - ).rejects.toMatchObject({ name: `AbortError` }) - const second = trace.collection._sync.loadSubset({ limit: 1 }) - expect(second).not.toBe(true) - await expect(Promise.resolve(second)).rejects.toMatchObject({ - name: `AbortError`, - }) - expect(trace.collection.size).toBe(0) - }, [() => snapshot.resolve(), () => trace.collection.cleanup()]) - }) + const snapshot = createDeferred() + mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) + const first = Promise.resolve( + trace.collection._sync.loadSubset({ limit: 1 }), + ) + void first.catch(() => undefined) + + await withElectricCleanup(async () => { + await vi.waitFor(() => + expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + ) + external.abort() + snapshot.resolve() + await expect( + atCheckpoint(first, `externally aborted subset snapshot`), + ).rejects.toMatchObject({ name: `AbortError` }) + const second = trace.collection._sync.loadSubset({ limit: 1 }) + expect(second).not.toBe(true) + await expect(Promise.resolve(second)).rejects.toMatchObject({ + name: `AbortError`, + }) + expect(trace.collection.size).toBe(0) + }, [() => snapshot.resolve(), () => trace.collection.cleanup()]) + }, + ) // Even a completed transport cannot certify a demand while its accepted // source commit is waiting for application. Retiring the stream during that // wait leaves no visible source row, so the demand must fail when the held // applied receipt finally settles. - it(`does not certify an externally aborted subset during applied settlement`, async () => { - const external = new AbortController() - const trace = createOracleCollection( - `subset-held-external-abort`, - `on-demand`, - createMetadata(new Map()).api, - { signal: external.signal }, - ) - const persistence = createDeferred() - const transaction = createTransaction({ - mutationFn: () => persistence.promise, - }) - transaction.mutate(() => - trace.collection.insert({ - id: 99, - name: `optimistic`, - stable: `stable-99`, - }), - ) - const snapshot = createDeferred() - mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) - const first = Promise.resolve( - trace.collection._sync.loadSubset({ limit: 1 }), - ) - void first.catch(() => undefined) - - await withElectricCleanup(async () => { - await vi.waitFor(() => - expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + oracleReplayTest(it)( + `does not certify an externally aborted subset during applied settlement`, + async () => { + const external = new AbortController() + const trace = createOracleCollection( + `subset-held-external-abort`, + `on-demand`, + createMetadata(new Map()).api, + { signal: external.signal }, ) - trace.subscriber([change(`insert`, 1, `source row`), subsetEnd]) - snapshot.resolve() - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(trace.collection.has(1), `applied receipt is held`).toBe(false) - external.abort() - persistence.resolve() - await transaction.isPersisted.promise - await expect( - atCheckpoint(first, `externally aborted held subset`), - ).rejects.toMatchObject({ name: `AbortError` }) - const second = trace.collection._sync.loadSubset({ limit: 1 }) - expect(second).not.toBe(true) - await expect(Promise.resolve(second)).rejects.toMatchObject({ - name: `AbortError`, + const persistence = createDeferred() + const transaction = createTransaction({ + mutationFn: () => persistence.promise, }) - expect(mockStream.requestSnapshot).toHaveBeenCalledOnce() - }, [ - () => snapshot.resolve(), - () => persistence.resolve(), - () => trace.collection.cleanup(), - ]) - }) + transaction.mutate(() => + trace.collection.insert({ + id: 99, + name: `optimistic`, + stable: `stable-99`, + }), + ) + const snapshot = createDeferred() + mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) + const first = Promise.resolve( + trace.collection._sync.loadSubset({ limit: 1 }), + ) + void first.catch(() => undefined) + + await withElectricCleanup(async () => { + await vi.waitFor(() => + expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + ) + trace.subscriber([change(`insert`, 1, `source row`), subsetEnd]) + snapshot.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(trace.collection.has(1), `applied receipt is held`).toBe(false) + external.abort() + persistence.resolve() + await transaction.isPersisted.promise + await expect( + atCheckpoint(first, `externally aborted held subset`), + ).rejects.toMatchObject({ name: `AbortError` }) + const second = trace.collection._sync.loadSubset({ limit: 1 }) + expect(second).not.toBe(true) + await expect(Promise.resolve(second)).rejects.toMatchObject({ + name: `AbortError`, + }) + expect(mockStream.requestSnapshot).toHaveBeenCalledOnce() + }, [ + () => snapshot.resolve(), + () => persistence.resolve(), + () => trace.collection.cleanup(), + ]) + }, + ) // Application can outlive the SDK request. A caller that releases its // demand during that interval must reject promptly, while the source // receipt remains held and a later acquisition can still wait for it. - it(`cancels a subset demand while its source receipt is held`, async () => { - const trace = createOracleCollection( - `subset-held-demand-abort`, - `on-demand`, - createMetadata(new Map()).api, - ) - const persistence = createDeferred() - const transaction = createTransaction({ - mutationFn: () => persistence.promise, - }) - transaction.mutate(() => - trace.collection.insert({ - id: 99, - name: `optimistic`, - stable: `stable-99`, - }), - ) - const snapshot = createDeferred() - mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) - const controller = new AbortController() - const first = Promise.resolve( - trace.collection._sync.loadSubset({ - limit: 1, - signal: controller.signal, - }), - ) - let firstOutcome: `pending` | `fulfilled` | `rejected` = `pending` - let firstError: unknown - const observedFirst = first.then( - () => { - firstOutcome = `fulfilled` - }, - (error: unknown) => { - firstOutcome = `rejected` - firstError = error - }, - ) - - await withElectricCleanup(async () => { - await vi.waitFor(() => - expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + oracleReplayTest(it)( + `cancels a subset demand while its source receipt is held`, + async () => { + const trace = createOracleCollection( + `subset-held-demand-abort`, + `on-demand`, + createMetadata(new Map()).api, ) - trace.subscriber([change(`insert`, 1, `source row`), subsetEnd]) - snapshot.resolve() - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(trace.collection.has(1)).toBe(false) - controller.abort() - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(firstOutcome).toBe(`rejected`) - expect(firstError).toMatchObject({ name: `AbortError` }) - persistence.resolve() - await transaction.isPersisted.promise - await observedFirst - expect(trace.collection.has(1)).toBe(true) - }, [ - () => snapshot.resolve(), - () => persistence.resolve(), - () => trace.collection.cleanup(), - ]) - }) + const persistence = createDeferred() + const transaction = createTransaction({ + mutationFn: () => persistence.promise, + }) + transaction.mutate(() => + trace.collection.insert({ + id: 99, + name: `optimistic`, + stable: `stable-99`, + }), + ) + const snapshot = createDeferred() + mockStream.requestSnapshot.mockReturnValueOnce(snapshot.promise) + const controller = new AbortController() + const first = Promise.resolve( + trace.collection._sync.loadSubset({ + limit: 1, + signal: controller.signal, + }), + ) + let firstOutcome: `pending` | `fulfilled` | `rejected` = `pending` + let firstError: unknown + const observedFirst = first.then( + () => { + firstOutcome = `fulfilled` + }, + (error: unknown) => { + firstOutcome = `rejected` + firstError = error + }, + ) + + await withElectricCleanup(async () => { + await vi.waitFor(() => + expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + ) + trace.subscriber([change(`insert`, 1, `source row`), subsetEnd]) + snapshot.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(trace.collection.has(1)).toBe(false) + controller.abort() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(firstOutcome).toBe(`rejected`) + expect(firstError).toMatchObject({ name: `AbortError` }) + persistence.resolve() + await transaction.isPersisted.promise + await observedFirst + expect(trace.collection.has(1)).toBe(true) + }, [ + () => snapshot.resolve(), + () => persistence.resolve(), + () => trace.collection.cleanup(), + ]) + }, + ) // A demand canceled before its subset transport settles has no successful // acquisition, whether that transport later resolves or rejects. The source // may still apply a late SDK response, but this caller observes AbortError and // an identical later demand remains eligible for a new acquisition. - it.each([`resolve`, `reject`] as const)( + oracleReplayTest(it).each([`resolve`, `reject`] as const)( `rejects a canceled subset demand when its transport later %ss`, async (transportOutcome) => { const trace = createOracleCollection( @@ -6330,229 +6358,248 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { // cancellation independently of the request ahead of it. At the next task // boundary after abort, this caller must have rejected without invoking a // second SDK request; the first demand remains pending until its response. - it(`cancels a queued subset demand while an earlier snapshot is held`, async () => { - const trace = createOracleCollection( - `queued-subset-abort`, - `on-demand`, - createMetadata(new Map()).api, - ) - const held = createDeferred() - mockStream.requestSnapshot.mockReturnValueOnce(held.promise) - const first = Promise.resolve( - trace.collection._sync.loadSubset({ limit: 1 }), - ) - const controller = new AbortController() - let secondOutcome: `pending` | `fulfilled` | `rejected` = `pending` - let secondError: unknown - const second = Promise.resolve( - trace.collection._sync.loadSubset({ - limit: 2, - signal: controller.signal, - }), - ).then( - () => { - secondOutcome = `fulfilled` - }, - (error: unknown) => { - secondOutcome = `rejected` - secondError = error - }, - ) - - await withElectricCleanup(async () => { - await vi.waitFor(() => - expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + oracleReplayTest(it)( + `cancels a queued subset demand while an earlier snapshot is held`, + async () => { + const trace = createOracleCollection( + `queued-subset-abort`, + `on-demand`, + createMetadata(new Map()).api, + ) + const held = createDeferred() + mockStream.requestSnapshot.mockReturnValueOnce(held.promise) + const first = Promise.resolve( + trace.collection._sync.loadSubset({ limit: 1 }), + ) + const controller = new AbortController() + let secondOutcome: `pending` | `fulfilled` | `rejected` = `pending` + let secondError: unknown + const second = Promise.resolve( + trace.collection._sync.loadSubset({ + limit: 2, + signal: controller.signal, + }), + ).then( + () => { + secondOutcome = `fulfilled` + }, + (error: unknown) => { + secondOutcome = `rejected` + secondError = error + }, ) - controller.abort() - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(secondOutcome).toBe(`rejected`) - expect(secondError).toMatchObject({ name: `AbortError` }) - expect(mockStream.requestSnapshot).toHaveBeenCalledOnce() - held.resolve() - await atCheckpoint(Promise.all([first, second]), `queued abort drain`) - }, [() => held.resolve(), () => trace.collection.cleanup()]) - }) - it(`drops a held receipt's rows when cleanup abandons it`, async () => { - const metadata = createMetadata(new Map()) - const trace = createOracleCollection( - `rejected-applied-receipt`, - `eager`, - metadata.api, - ) - const persistence = createDeferred() - const transaction = createTransaction({ - mutationFn: () => persistence.promise, - }) - transaction.mutate(() => - trace.collection.insert({ - id: 99, - name: `optimistic`, - stable: `stable-99`, - }), - ) - trace.subscriber([change(`insert`, 1, `synced`), upToDate]) - await Promise.resolve() - expect(trace.collection.status).toBe(`ready`) + await withElectricCleanup(async () => { + await vi.waitFor(() => + expect(mockStream.requestSnapshot).toHaveBeenCalledOnce(), + ) + controller.abort() + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(secondOutcome).toBe(`rejected`) + expect(secondError).toMatchObject({ name: `AbortError` }) + expect(mockStream.requestSnapshot).toHaveBeenCalledOnce() + held.resolve() + await atCheckpoint(Promise.all([first, second]), `queued abort drain`) + }, [() => held.resolve(), () => trace.collection.cleanup()]) + }, + ) - await trace.collection.cleanup() - persistence.resolve() - await transaction.isPersisted.promise - await Promise.resolve() + oracleReplayTest(it)( + `drops a held receipt's rows when cleanup abandons it`, + async () => { + const metadata = createMetadata(new Map()) + const trace = createOracleCollection( + `rejected-applied-receipt`, + `eager`, + metadata.api, + ) + const persistence = createDeferred() + const transaction = createTransaction({ + mutationFn: () => persistence.promise, + }) + transaction.mutate(() => + trace.collection.insert({ + id: 99, + name: `optimistic`, + stable: `stable-99`, + }), + ) + trace.subscriber([change(`insert`, 1, `synced`), upToDate]) + await Promise.resolve() + expect(trace.collection.status).toBe(`ready`) - expect(trace.collection.status).toBe(`cleaned-up`) - expect(trace.collection._lifecycle.getSyncError()).toBeUndefined() - expect(trace.collection.has(1)).toBe(false) - }) + await trace.collection.cleanup() + persistence.resolve() + await transaction.isPersisted.promise + await Promise.resolve() - it(`accepts partial resumed updates for rows restored by persistence`, async () => { - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const collectionMetadata = new Map(resumeState()) - const persistedRows = new Map([ - [ - 1, - { - id: 1, - name: `persisted`, - stable: `stable-1`, - }, - ], - ]) - const electricOptions = electricCollectionOptions({ - id: `persisted-resume-oracle`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - syncMode: `progressive`, - getKey: (row) => row.id, - startSync: true, - }) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricOptions, - persistence: { - adapter: createPersistedAdapter(collectionMetadata, persistedRows), + expect(trace.collection.status).toBe(`cleaned-up`) + expect(trace.collection._lifecycle.getSyncError()).toBeUndefined() + expect(trace.collection.has(1)).toBe(false) + }, + ) + + oracleReplayTest(it)( + `accepts partial resumed updates for rows restored by persistence`, + async () => { + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const collectionMetadata = new Map(resumeState()) + const persistedRows = new Map([ + [ + 1, + { + id: 1, + name: `persisted`, + stable: `stable-1`, + }, + ], + ]) + const electricOptions = electricCollectionOptions({ + id: `persisted-resume-oracle`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, }, - }), - ) + syncMode: `progressive`, + getKey: (row) => row.id, + startSync: true, + }) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricOptions, + persistence: { + adapter: createPersistedAdapter(collectionMetadata, persistedRows), + }, + }), + ) - collection.startSyncImmediate() - await collection._sync.loadSubset({ limit: 10 }) - expect(collection.get(1)).toEqual( - expect.objectContaining({ name: `persisted`, stable: `stable-1` }), - ) - expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ - offset: `10_0`, - handle: `shape-1`, - }) + collection.startSyncImmediate() + await collection._sync.loadSubset({ limit: 10 }) + expect(collection.get(1)).toEqual( + expect.objectContaining({ name: `persisted`, stable: `stable-1` }), + ) + expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ + offset: `10_0`, + handle: `shape-1`, + }) - subscriber([change(`update`, 1, `resumed update`), upToDate]) - await vi.waitFor(() => expect(collection.status).toBe(`ready`)) + subscriber([change(`update`, 1, `resumed update`), upToDate]) + await vi.waitFor(() => expect(collection.status).toBe(`ready`)) - expect(collection.get(1)).toEqual( - expect.objectContaining({ - name: `resumed update`, - stable: `stable-1`, - }), - ) - await vi.waitFor(() => { - expect(persistedRows.get(1)).toEqual( + expect(collection.get(1)).toEqual( expect.objectContaining({ name: `resumed update`, stable: `stable-1`, }), ) - }) - await collection.cleanup() - }) + await vi.waitFor(() => { + expect(persistedRows.get(1)).toEqual( + expect.objectContaining({ + name: `resumed update`, + stable: `stable-1`, + }), + ) + }) + await collection.cleanup() + }, + ) - it(`buffers resumed updates that arrive while persisted rows are hydrating`, async () => { - let subscriber: ((messages: Array>) => void) | undefined - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const hydration = createDeferred() - const collectionMetadata = new Map(resumeState()) - const persistedRows = new Map([ - [ - 1, - { - id: 1, - name: `persisted`, - stable: `stable-1`, - }, - ], - ]) - const electricOptions = electricCollectionOptions({ - id: `concurrent-persisted-resume-oracle`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - syncMode: `progressive`, - getKey: (row) => row.id, - startSync: true, - }) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricOptions, - persistence: { - adapter: createPersistedAdapter( - collectionMetadata, - persistedRows, - hydration.promise, - ), + oracleReplayTest(it)( + `buffers resumed updates that arrive while persisted rows are hydrating`, + async () => { + let subscriber: + ((messages: Array>) => void) | undefined + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const hydration = createDeferred() + const collectionMetadata = new Map(resumeState()) + const persistedRows = new Map([ + [ + 1, + { + id: 1, + name: `persisted`, + stable: `stable-1`, + }, + ], + ]) + const electricOptions = electricCollectionOptions({ + id: `concurrent-persisted-resume-oracle`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, }, - }), - ) + syncMode: `progressive`, + getKey: (row) => row.id, + startSync: true, + }) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricOptions, + persistence: { + adapter: createPersistedAdapter( + collectionMetadata, + persistedRows, + hydration.promise, + ), + }, + }), + ) - collection.startSyncImmediate() - await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) - expect(collection.status).not.toBe(`error`) - subscriber!([change(`update`, 1, `concurrent update`), upToDate]) + collection.startSyncImmediate() + await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) + expect(collection.status).not.toBe(`error`) + subscriber!([change(`update`, 1, `concurrent update`), upToDate]) - expect(collection.status).not.toBe(`error`) - hydration.resolve() - await vi.waitFor(() => expect(collection.status).toBe(`ready`)) - expect(collection.get(1)).toEqual( - expect.objectContaining({ - name: `concurrent update`, - stable: `stable-1`, - }), - ) - await collection.cleanup() - }) + expect(collection.status).not.toBe(`error`) + hydration.resolve() + await vi.waitFor(() => expect(collection.status).toBe(`ready`)) + expect(collection.get(1)).toEqual( + expect.objectContaining({ + name: `concurrent update`, + stable: `stable-1`, + }), + ) + await collection.cleanup() + }, + ) - it(`does not advance durable Electric resume metadata past a rejected row commit`, async () => { - await runRejectedDurablePrefixWitness(`durable-prefix-fixed`, [ - `first`, - `second`, - ]) - }) + oracleReplayTest(it)( + `does not advance durable Electric resume metadata past a rejected row commit`, + async () => { + await runRejectedDurablePrefixWitness(`durable-prefix-fixed`, [ + `first`, + `second`, + ]) + }, + ) - it(`retains an accepted Electric source prefix when the next row commit rejects`, async () => { - await runRejectedDurablePrefixWitness( - `durable-prefix-after-success`, - [`accepted`, `rejected`], - true, - ) - }) + oracleReplayTest(it)( + `retains an accepted Electric source prefix when the next row commit rejects`, + async () => { + await runRejectedDurablePrefixWitness( + `durable-prefix-after-success`, + [`accepted`, `rejected`], + true, + ) + }, + ) const durablePrefixArbitrary = fc.tuple( fc.string({ maxLength: 12 }), @@ -6564,17 +6611,23 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { names, ) } - fcTest.prop([durablePrefixArbitrary], durablePrefixPropertyOptions(`fixed`))( + oracleReplayTest(fcTest).prop( + [durablePrefixArbitrary], + durablePrefixPropertyOptions(`fixed`), + )( `a rejected initial Electric row commit creates no durable source prefix (fixed)`, checkDurablePrefix, ) - // Replay the random lane with the legacy seed/path variables and -t 'random'. - fcTest.prop([durablePrefixArbitrary], durablePrefixPropertyOptions(`random`))( + // A shared seed reseeds this random lane; a path replay selects it by property. + oracleReplayTest(fcTest, durablePrefixProperty).prop( + [durablePrefixArbitrary], + durablePrefixPropertyOptions(`random`), + )( `a rejected initial Electric row commit creates no durable source prefix (random)`, checkDurablePrefix, ) - fcTest.prop( + oracleReplayTest(fcTest).prop( [ fc.uniqueArray(fc.integer({ min: 1, max: 20 }), { minLength: 2, @@ -6607,168 +6660,180 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ) }) - it(`rehydrates persisted rows and resume metadata after cleanup and restart`, async () => { - const subscribers: Array<(messages: Array>) => void> = [] - mockSubscribe.mockImplementation((callback) => { - subscribers.push(callback) - return vi.fn() - }) - const collectionMetadata = new Map(resumeState()) - const persistedRows = new Map([ - [1, { id: 1, name: `persisted`, stable: `stable-1` }], - ]) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricCollectionOptions({ - id: `persisted-restart-oracle`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, + oracleReplayTest(it)( + `rehydrates persisted rows and resume metadata after cleanup and restart`, + async () => { + const subscribers: Array<(messages: Array>) => void> = + [] + mockSubscribe.mockImplementation((callback) => { + subscribers.push(callback) + return vi.fn() + }) + const collectionMetadata = new Map(resumeState()) + const persistedRows = new Map([ + [1, { id: 1, name: `persisted`, stable: `stable-1` }], + ]) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `persisted-restart-oracle`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `progressive`, + getKey: (row) => row.id, + startSync: true, + }), + persistence: { + adapter: createPersistedAdapter(collectionMetadata, persistedRows), }, - syncMode: `progressive`, - getKey: (row) => row.id, - startSync: true, }), - persistence: { - adapter: createPersistedAdapter(collectionMetadata, persistedRows), - }, - }), - ) + ) - collection.startSyncImmediate() - await vi.waitFor(() => expect(collection.get(1)?.name).toBe(`persisted`)) - await vi.waitFor(() => expect(subscribers).toHaveLength(1)) - subscribers[0]!([upToDate]) - await collection.stateWhenReady() + collection.startSyncImmediate() + await vi.waitFor(() => expect(collection.get(1)?.name).toBe(`persisted`)) + await vi.waitFor(() => expect(subscribers).toHaveLength(1)) + subscribers[0]!([upToDate]) + await collection.stateWhenReady() - await collection.cleanup() - collection.startSyncImmediate() - await vi.waitFor(() => expect(subscribers).toHaveLength(2)) - await vi.waitFor(() => expect(collection.get(1)?.name).toBe(`persisted`)) - expect(collectionMetadata.get(`electric:resume`)).toEqual( - expect.objectContaining({ kind: `resume` }), - ) - await collection.cleanup() - }) + await collection.cleanup() + collection.startSyncImmediate() + await vi.waitFor(() => expect(subscribers).toHaveLength(2)) + await vi.waitFor(() => expect(collection.get(1)?.name).toBe(`persisted`)) + expect(collectionMetadata.get(`electric:resume`)).toEqual( + expect.objectContaining({ kind: `resume` }), + ) + await collection.cleanup() + }, + ) - it(`does not let hydration from a cleaned-up lifecycle poison restart`, async () => { - const subscribers: Array<(messages: Array>) => void> = [] - mockSubscribe.mockImplementation((callback) => { - subscribers.push(callback) - return vi.fn() - }) - const firstHydration = createDeferred() - const collectionMetadata = new Map(resumeState()) - const persistedRows = new Map([ - [1, { id: 1, name: `current`, stable: `stable-1` }], - ]) - const adapter = createPersistedAdapter(collectionMetadata, persistedRows) - const loadResumeSnapshot = adapter.loadResumeSnapshot - let hydrationCall = 0 - adapter.loadResumeSnapshot = vi.fn(async (collectionId, ctx) => { - const snapshot = await loadResumeSnapshot(collectionId, ctx) - if (ctx?.includeRows === false) return snapshot - hydrationCall++ - if (hydrationCall === 1) { - await firstHydration.promise - return { - ...snapshot, - rows: [ - { - key: 1, - value: { id: 1, name: `stale`, stable: `stable-1` }, - }, - ], + oracleReplayTest(it)( + `does not let hydration from a cleaned-up lifecycle poison restart`, + async () => { + const subscribers: Array<(messages: Array>) => void> = + [] + mockSubscribe.mockImplementation((callback) => { + subscribers.push(callback) + return vi.fn() + }) + const firstHydration = createDeferred() + const collectionMetadata = new Map(resumeState()) + const persistedRows = new Map([ + [1, { id: 1, name: `current`, stable: `stable-1` }], + ]) + const adapter = createPersistedAdapter(collectionMetadata, persistedRows) + const loadResumeSnapshot = adapter.loadResumeSnapshot + let hydrationCall = 0 + adapter.loadResumeSnapshot = vi.fn(async (collectionId, ctx) => { + const snapshot = await loadResumeSnapshot(collectionId, ctx) + if (ctx?.includeRows === false) return snapshot + hydrationCall++ + if (hydrationCall === 1) { + await firstHydration.promise + return { + ...snapshot, + rows: [ + { + key: 1, + value: { id: 1, name: `stale`, stable: `stable-1` }, + }, + ], + } } - } - return snapshot - }) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricCollectionOptions({ - id: `persisted-stale-hydration-oracle`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - syncMode: `progressive`, - getKey: (row) => row.id, - startSync: true, + return snapshot + }) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `persisted-stale-hydration-oracle`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `progressive`, + getKey: (row) => row.id, + startSync: true, + }), + persistence: { adapter }, }), - persistence: { adapter }, - }), - ) - - collection.startSyncImmediate() - await vi.waitFor(() => expect(hydrationCall).toBe(1)) - await collection.cleanup() - collection.startSyncImmediate() - await vi.waitFor(() => expect(subscribers).toHaveLength(2)) + ) - firstHydration.resolve() - await vi.waitFor(() => expect(hydrationCall).toBe(2)) - await vi.waitFor(() => expect(collection.get(1)?.name).toBe(`current`)) - subscribers[1]!([upToDate]) - await collection.stateWhenReady() - await collection.cleanup() - }) + collection.startSyncImmediate() + await vi.waitFor(() => expect(hydrationCall).toBe(1)) + await collection.cleanup() + collection.startSyncImmediate() + await vi.waitFor(() => expect(subscribers).toHaveLength(2)) + + firstHydration.resolve() + await vi.waitFor(() => expect(hydrationCall).toBe(2)) + await vi.waitFor(() => expect(collection.get(1)?.name).toBe(`current`)) + subscribers[1]!([upToDate]) + await collection.stateWhenReady() + await collection.cleanup() + }, + ) - it(`does not hydrate persisted on-demand rows before subset demand`, async () => { - let subscriber: ((messages: Array>) => void) | undefined - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const collectionMetadata = new Map() - const persistedRows = new Map([ - [1, { id: 1, name: `persisted`, stable: `stable-1` }], - ]) - const adapter = createPersistedAdapter(collectionMetadata, persistedRows) - const loadSubset = vi.fn(adapter.loadSubset) - adapter.loadSubset = loadSubset - const electricOptions = electricCollectionOptions({ - id: `persisted-on-demand-oracle`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - syncMode: `on-demand`, - getKey: (row) => row.id, - startSync: true, - }) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricOptions, - persistence: { adapter }, - }), - ) + oracleReplayTest(it)( + `does not hydrate persisted on-demand rows before subset demand`, + async () => { + let subscriber: + ((messages: Array>) => void) | undefined + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const collectionMetadata = new Map() + const persistedRows = new Map([ + [1, { id: 1, name: `persisted`, stable: `stable-1` }], + ]) + const adapter = createPersistedAdapter(collectionMetadata, persistedRows) + const loadSubset = vi.fn(adapter.loadSubset) + adapter.loadSubset = loadSubset + const electricOptions = electricCollectionOptions({ + id: `persisted-on-demand-oracle`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `on-demand`, + getKey: (row) => row.id, + startSync: true, + }) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricOptions, + persistence: { adapter }, + }), + ) - collection.startSyncImmediate() - await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) - subscriber!([upToDate]) - await vi.waitFor(() => expect(collection.status).toBe(`ready`)) + collection.startSyncImmediate() + await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) + subscriber!([upToDate]) + await vi.waitFor(() => expect(collection.status).toBe(`ready`)) - expect(loadSubset).not.toHaveBeenCalled() - expect(collection.has(1)).toBe(false) - await collection.cleanup() - }) + expect(loadSubset).not.toHaveBeenCalled() + expect(collection.has(1)).toBe(false) + await collection.cleanup() + }, + ) - it.each( + oracleReplayTest(it).each( [`reset`, `delete`, `move-out`].flatMap((removal) => [`none`, `before-commit`, `after-commit`].map((acquire) => ({ removal, @@ -6920,55 +6985,58 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ], ) - it(`applies on-demand catch-up updates to hydrated persisted rows`, async () => { - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const collectionMetadata = new Map(resumeState()) - const persistedRows = new Map([ - [1, { id: 1, name: `persisted`, stable: `stable-1` }], - ]) - const collection = createCollection( - persistedCollectionOptions< - OracleRow, - string | number, - never, - ElectricCollectionUtils - >({ - ...electricCollectionOptions({ - id: `on-demand-persisted-catch-up`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, + oracleReplayTest(it)( + `applies on-demand catch-up updates to hydrated persisted rows`, + async () => { + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const collectionMetadata = new Map(resumeState()) + const persistedRows = new Map([ + [1, { id: 1, name: `persisted`, stable: `stable-1` }], + ]) + const collection = createCollection( + persistedCollectionOptions< + OracleRow, + string | number, + never, + ElectricCollectionUtils + >({ + ...electricCollectionOptions({ + id: `on-demand-persisted-catch-up`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + syncMode: `on-demand`, + getKey: (row) => row.id, + startSync: true, + }), + persistence: { + adapter: createPersistedAdapter(collectionMetadata, persistedRows), }, - syncMode: `on-demand`, - getKey: (row) => row.id, - startSync: true, }), - persistence: { - adapter: createPersistedAdapter(collectionMetadata, persistedRows), - }, - }), - ) + ) - collection.startSyncImmediate() - await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) - await collection._sync.loadSubset({}) - subscriber([change(`update`, 1, `caught up`), upToDate]) - await vi.waitFor(() => expect(collection.status).toBe(`ready`)) + collection.startSyncImmediate() + await vi.waitFor(() => expect(subscriber).toBeTypeOf(`function`)) + await collection._sync.loadSubset({}) + subscriber([change(`update`, 1, `caught up`), upToDate]) + await vi.waitFor(() => expect(collection.status).toBe(`ready`)) - expect(collection.get(1)).toEqual( - expect.objectContaining({ name: `caught up`, stable: `stable-1` }), - ) - expect(collectionMetadata.get(`electric:resume`)).toEqual( - expect.objectContaining({ kind: `resume`, offset: `20_0` }), - ) - await collection.cleanup() - }) + expect(collection.get(1)).toEqual( + expect.objectContaining({ name: `caught up`, stable: `stable-1` }), + ) + expect(collectionMetadata.get(`electric:resume`)).toEqual( + expect.objectContaining({ kind: `resume`, offset: `20_0` }), + ) + await collection.cleanup() + }, + ) - it.each([ + oracleReplayTest(it).each([ { name: `malformed`, seed: new Map([ @@ -7031,30 +7099,33 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { await trace.collection.cleanup() }) - it(`does not mix an explicit resume option with persisted metadata`, async () => { - const metadata = createMetadata(resumeState()) - const trace = createOracleCollection( - `explicit-resume`, - `on-demand`, - metadata.api, - { handle: `explicit-handle` }, - ) - - expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ - offset: `now`, - handle: `explicit-handle`, - }) - trace.subscriber([upToDate]) - expect(trace.collection.status).toBe(`ready`) - await trace.collection.cleanup() - }) - + oracleReplayTest(it)( + `does not mix an explicit resume option with persisted metadata`, + async () => { + const metadata = createMetadata(resumeState()) + const trace = createOracleCollection( + `explicit-resume`, + `on-demand`, + metadata.api, + { handle: `explicit-handle` }, + ) + + expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ + offset: `now`, + handle: `explicit-handle`, + }) + trace.subscriber([upToDate]) + expect(trace.collection.status).toBe(`ready`) + await trace.collection.cleanup() + }, + ) + // An explicit source cursor owns this run's resume decision. A malformed // durable record is still invalid for a later implicit run, but it cannot // authorize this run to write a reset before any source evidence arrives. // The metadata Map observes that side effect at startup, before up-to-date // can legitimately stage a fresh resume record. - it.each([ + oracleReplayTest(it).each([ { name: `offset`, options: { offset: `10_0` as Offset } }, { name: `handle`, options: { handle: `explicit-handle` } }, ])( @@ -7076,54 +7147,57 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it(`lets an equal-timestamp reset dominate a stale hydrated resume`, async () => { - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const options = electricCollectionOptions({ - id: `equal-timestamp-reset`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - getKey: (row) => row.id, - startSync: true, - }) - options.sync.importSyncMeta?.({ - version: 1, - resume: { - kind: `resume`, - requiresTagState: false, - offset: `10_0`, - handle: `stale-handle`, - shapeId, - updatedAt: 1, - }, - seenTxids: [], - }) - const originalSync = options.sync - const metadata = createMetadata( - new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), - ) - const collection = createCollection({ - ...options, - sync: { - ...originalSync, - sync: (params: Parameters[0]) => - originalSync.sync({ ...params, metadata: metadata.api }), - }, - }) + oracleReplayTest(it)( + `lets an equal-timestamp reset dominate a stale hydrated resume`, + async () => { + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const options = electricCollectionOptions({ + id: `equal-timestamp-reset`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + startSync: true, + }) + options.sync.importSyncMeta?.({ + version: 1, + resume: { + kind: `resume`, + requiresTagState: false, + offset: `10_0`, + handle: `stale-handle`, + shapeId, + updatedAt: 1, + }, + seenTxids: [], + }) + const originalSync = options.sync + const metadata = createMetadata( + new Map([[`electric:resume`, { kind: `reset`, updatedAt: 1 }]]), + ) + const collection = createCollection({ + ...options, + sync: { + ...originalSync, + sync: (params: Parameters[0]) => + originalSync.sync({ ...params, metadata: metadata.api }), + }, + }) - expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ - offset: undefined, - handle: undefined, - }) - subscriber([change(`insert`, 1, `full snapshot`), upToDate]) - expect(collection.status).toBe(`ready`) - await collection.cleanup() - }) + expect(vi.mocked(ShapeStream).mock.calls.at(-1)?.[0]).toMatchObject({ + offset: undefined, + handle: undefined, + }) + subscriber([change(`insert`, 1, `full snapshot`), upToDate]) + expect(collection.status).toBe(`ready`) + await collection.cleanup() + }, + ) electricHistoryProperty( `electric.resume-metadata-merge`, @@ -7362,94 +7436,106 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ], ) - it(`retains the selected tag requirement when timestamps establish an order`, () => { - const merge = electricCollectionOptions({ - shapeOptions: { url: `http://test-url`, params: { table: `test_table` } }, - getKey: (row) => row.id, - }).sync.mergeSyncMeta! - for (const olderTag of [undefined, false, true]) { - for (const newerTag of [undefined, false, true]) { - const state = (updatedAt: number, tag: boolean | undefined) => ({ - version: 1, - seenTxids: [updatedAt], - resume: { - kind: `resume`, - offset: `10_0`, - handle: `shape-1`, - shapeId, - updatedAt, - ...(tag === undefined ? {} : { requiresTagState: tag }), - }, - }) - const older = state(1, olderTag) - const newer = state(2, newerTag) - const expected = { ...newer, seenTxids: [1, 2] } - expect(merge(older, newer)).toEqual(expected) - expect(merge(newer, older)).toEqual(expected) + oracleReplayTest(it)( + `retains the selected tag requirement when timestamps establish an order`, + () => { + const merge = electricCollectionOptions({ + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, + }, + getKey: (row) => row.id, + }).sync.mergeSyncMeta! + for (const olderTag of [undefined, false, true]) { + for (const newerTag of [undefined, false, true]) { + const state = (updatedAt: number, tag: boolean | undefined) => ({ + version: 1, + seenTxids: [updatedAt], + resume: { + kind: `resume`, + offset: `10_0`, + handle: `shape-1`, + shapeId, + updatedAt, + ...(tag === undefined ? {} : { requiresTagState: tag }), + }, + }) + const older = state(1, olderTag) + const newer = state(2, newerTag) + const expected = { ...newer, seenTxids: [1, 2] } + expect(merge(older, newer)).toEqual(expected) + expect(merge(newer, older)).toEqual(expected) + } } - } - }) + }, + ) - it(`rejects an unseen partial update from an explicit eager resume`, async () => { - const metadata = createMetadata(resumeState()) - const trace = createOracleCollection( - `explicit-eager-resume`, - `eager`, - metadata.api, - { offset: `10_0` as Offset, handle: `explicit-handle` }, - ) + oracleReplayTest(it)( + `rejects an unseen partial update from an explicit eager resume`, + async () => { + const metadata = createMetadata(resumeState()) + const trace = createOracleCollection( + `explicit-eager-resume`, + `eager`, + metadata.api, + { offset: `10_0` as Offset, handle: `explicit-handle` }, + ) - trace.subscriber([change(`update`, 1, `partial`), upToDate]) + trace.subscriber([change(`update`, 1, `partial`), upToDate]) - expect(trace.collection.status).toBe(`error`) - expect(trace.collection.has(1)).toBe(false) - expect(metadata.state.get(`electric:resume`)).toEqual( - expect.objectContaining({ kind: `reset` }), - ) - await trace.collection.cleanup() - }) + expect(trace.collection.status).toBe(`error`) + expect(trace.collection.has(1)).toBe(false) + expect(metadata.state.get(`electric:resume`)).toEqual( + expect.objectContaining({ kind: `reset` }), + ) + await trace.collection.cleanup() + }, + ) - it(`accepts complete replica updates from an explicit eager resume`, async () => { - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return vi.fn() - }) - const collection = createCollection( - electricCollectionOptions({ - id: `explicit-full-replica-resume`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table`, replica: `full` }, - offset: `10_0` as Offset, - handle: `explicit-handle`, - }, - syncMode: `eager`, - getKey: (row) => row.id, - startSync: true, - }), - ) + oracleReplayTest(it)( + `accepts complete replica updates from an explicit eager resume`, + async () => { + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return vi.fn() + }) + const collection = createCollection( + electricCollectionOptions({ + id: `explicit-full-replica-resume`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table`, replica: `full` }, + offset: `10_0` as Offset, + handle: `explicit-handle`, + }, + syncMode: `eager`, + getKey: (row) => row.id, + startSync: true, + }), + ) - subscriber([ - { - key: `1`, - value: { id: 1, name: `complete update`, stable: `stable-1` }, - headers: { operation: `update` }, - }, - upToDate, - ]) + subscriber([ + { + key: `1`, + value: { id: 1, name: `complete update`, stable: `stable-1` }, + headers: { operation: `update` }, + }, + upToDate, + ]) - expect(collection.status).toBe(`ready`) - expect(collection.get(1)).toEqual( - expect.objectContaining({ - name: `complete update`, - stable: `stable-1`, - }), - ) - await collection.cleanup() - }) + expect(collection.status).toBe(`ready`) + expect(collection.get(1)).toEqual( + expect.objectContaining({ + name: `complete update`, + stable: `stable-1`, + }), + ) + await collection.cleanup() + }, + ) - it.each([10, 100])( + oracleReplayTest(it).each([10, 100])( `subset acquisition avoids scanning the applied baseline with %s rows`, async (size) => { const trace = createOracleCollection( @@ -7488,119 +7574,131 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { }, ) - it(`ignores an unseen on-demand update without blocking readiness`, async () => { - const metadata = createMetadata(resumeState()) - const trace = createOracleCollection( - `on-demand-unseen-update`, - `on-demand`, - metadata.api, - ) + oracleReplayTest(it)( + `ignores an unseen on-demand update without blocking readiness`, + async () => { + const metadata = createMetadata(resumeState()) + const trace = createOracleCollection( + `on-demand-unseen-update`, + `on-demand`, + metadata.api, + ) - trace.subscriber([change(`update`, 1, `partial`), upToDate]) + trace.subscriber([change(`update`, 1, `partial`), upToDate]) - expect(trace.collection.status).toBe(`ready`) - expect(trace.collection.has(1)).toBe(false) - expect(metadata.state.get(`electric:resume`)).toEqual( - expect.objectContaining({ kind: `resume`, offset: `20_0` }), - ) - await trace.collection.cleanup() - }) + expect(trace.collection.status).toBe(`ready`) + expect(trace.collection.has(1)).toBe(false) + expect(metadata.state.get(`electric:resume`)).toEqual( + expect.objectContaining({ kind: `resume`, offset: `20_0` }), + ) + await trace.collection.cleanup() + }, + ) - it(`records match and txid evidence for an ignored on-demand update`, async () => { - const trace = createOracleCollection( - `on-demand-ignored-update-evidence`, - `on-demand`, - createMetadata(resumeState()).api, - ) - trace.subscriber([upToDate]) + oracleReplayTest(it)( + `records match and txid evidence for an ignored on-demand update`, + async () => { + const trace = createOracleCollection( + `on-demand-ignored-update-evidence`, + `on-demand`, + createMetadata(resumeState()).api, + ) + trace.subscriber([upToDate]) - const pendingMatch = trace.collection.utils.awaitMatch( - (message) => `value` in message && message.value.id === 808, - 100, - ) - const pendingTxid = trace.collection.utils.awaitTxId(808, 100) - const update = change(`update`, 808, `ignored`) - update.headers.txids = [808] + const pendingMatch = trace.collection.utils.awaitMatch( + (message) => `value` in message && message.value.id === 808, + 100, + ) + const pendingTxid = trace.collection.utils.awaitTxId(808, 100) + const update = change(`update`, 808, `ignored`) + update.headers.txids = [808] - trace.subscriber([update, upToDate]) + trace.subscriber([update, upToDate]) - await Promise.all([ - expect(pendingMatch).resolves.toBe(true), - expect(pendingTxid).resolves.toBe(true), - ]) - expect(trace.collection.has(808)).toBe(false) - await trace.collection.cleanup() - }) + await Promise.all([ + expect(pendingMatch).resolves.toBe(true), + expect(pendingTxid).resolves.toBe(true), + ]) + expect(trace.collection.has(808)).toBe(false) + await trace.collection.cleanup() + }, + ) - it(`reconstructs persistence-interleaving campaigns and rejects ablated, out-of-range, and foreign histories`, () => { - const campaigns = fc.sample(persistenceInterleavingCampaignArbitrary, { - seed: 18_530_601, - numRuns: 20, - }) - for (const campaign of campaigns) { - expect(reconstructPersistenceInterleavingCampaign(campaign)).toEqual( - campaign, - ) - expect(() => - reconstructPersistenceInterleavingCampaign({ - names: campaign.names, - }), - ).toThrow() - expect(() => - reconstructPersistenceInterleavingCampaign({ - histories: campaign.histories, - }), - ).toThrow() + oracleReplayTest(it)( + `reconstructs persistence-interleaving campaigns and rejects ablated, out-of-range, and foreign histories`, + () => { + const campaigns = fc.sample(persistenceInterleavingCampaignArbitrary, { + seed: 18_530_601, + numRuns: 20, + }) + for (const campaign of campaigns) { + expect(reconstructPersistenceInterleavingCampaign(campaign)).toEqual( + campaign, + ) + expect(() => + reconstructPersistenceInterleavingCampaign({ + names: campaign.names, + }), + ).toThrow() + expect(() => + reconstructPersistenceInterleavingCampaign({ + histories: campaign.histories, + }), + ).toThrow() + expect(() => + reconstructPersistenceInterleavingCampaign({ + ...campaign, + histories: [`open-transaction-hydration`, `foreign`], + }), + ).toThrow() + expect(() => + reconstructPersistenceInterleavingCampaign({ + ...campaign, + names: [``, campaign.names[1]], + }), + ).toThrow() + } + }, + ) + + oracleReplayTest(it)( + `rejects named wrong answers for persistence interleavings`, + () => { + const names: [string, string] = [`before`, `after`] expect(() => - reconstructPersistenceInterleavingCampaign({ - ...campaign, - histories: [`open-transaction-hydration`, `foreign`], - }), + expectPersistenceInterleavingObservation( + { + kind: `open-transaction-hydration`, + hydrationStartedBeforeCommit: true, + hydrationOutcomeBeforeRelease: `AbortError`, + cachedRowBeforeRelease: true, + hydrationOutcome: `fulfilled`, + publicRowsAtHydrationSettlement: [], + deliveryErrors: [`InvalidPersistedCollectionConfigError`], + publicRows: [], + durableRows: [], + status: `ready`, + publicError: undefined, + }, + names, + ), ).toThrow() expect(() => - reconstructPersistenceInterleavingCampaign({ - ...campaign, - names: [``, campaign.names[1]], - }), + expectPersistenceInterleavingObservation( + { + kind: `subset-supersedes-absence`, + publicRows: [], + durableRows: [], + status: `ready`, + publicError: undefined, + }, + names, + ), ).toThrow() - } - }) - - it(`rejects named wrong answers for persistence interleavings`, () => { - const names: [string, string] = [`before`, `after`] - expect(() => - expectPersistenceInterleavingObservation( - { - kind: `open-transaction-hydration`, - hydrationStartedBeforeCommit: true, - hydrationOutcomeBeforeRelease: `AbortError`, - cachedRowBeforeRelease: true, - hydrationOutcome: `fulfilled`, - publicRowsAtHydrationSettlement: [], - deliveryErrors: [`InvalidPersistedCollectionConfigError`], - publicRows: [], - durableRows: [], - status: `ready`, - publicError: undefined, - }, - names, - ), - ).toThrow() - expect(() => - expectPersistenceInterleavingObservation( - { - kind: `subset-supersedes-absence`, - publicRows: [], - durableRows: [], - status: `ready`, - publicError: undefined, - }, - names, - ), - ).toThrow() - }) + }, + ) - fcTest.prop([persistenceInterleavingCampaignArbitrary], { + oracleReplayTest(fcTest).prop([persistenceInterleavingCampaignArbitrary], { seed: 18_530_601, numRuns: oracleRuns(4), })( @@ -7608,7 +7706,7 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { runPersistenceInterleavingCampaign, ) - fcTest.prop( + oracleReplayTest(fcTest).prop( [persistenceInterleavingCampaignArbitrary], oraclePropertyOptions(4, persistenceInterleavingProperty), )( @@ -7617,32 +7715,36 @@ describeUnlessQueuedPresenceReplay(`Electric adapter laws`, () => { ) }) -if ( - requestedPersistenceInterleavingProperty === persistenceInterleavingProperty -) { - describe(`persisted Electric interleaving replay`, () => { - beforeEach(resetElectricOracleMocks) - - fcTest.prop( - [persistenceInterleavingCampaignArbitrary], - oraclePropertyOptions(4, persistenceInterleavingProperty), - )( - `preserves persisted Electric interleavings (replay)`, - runPersistenceInterleavingCampaign, - ) - }) -} - -if (requestedQueuedPresenceProperty === queuedPresenceProperty) { - describe(`Electric queued-presence replay`, () => { - beforeEach(resetElectricOracleMocks) +// The replay suites register only during a path replay. Each stands aside +// unless the replay selects its own property. +if (oracleReplayRequested()) { + oracleReplayTest(describe, persistenceInterleavingProperty)( + `persisted Electric interleaving replay`, + () => { + beforeEach(resetElectricOracleMocks) + + fcTest.prop( + [persistenceInterleavingCampaignArbitrary], + oraclePropertyOptions(4, persistenceInterleavingProperty), + )( + `preserves persisted Electric interleavings (replay)`, + runPersistenceInterleavingCampaign, + ) + }, + ) - fcTest.prop( - queuedPresenceArbitraries, - queuedPresencePropertyOptions(`replay`), - )( - `generated queued Electric histories preserve staged presence and reset fences (replay)`, - runQueuedPresenceCampaign, - ) - }) + oracleReplayTest(describe, queuedPresenceProperty)( + `Electric queued-presence replay`, + () => { + beforeEach(resetElectricOracleMocks) + + fcTest.prop( + queuedPresenceArbitraries, + oraclePropertyOptions(queuedPresenceRuns, queuedPresenceProperty), + )( + `generated queued Electric histories preserve staged presence and reset fences (replay)`, + runQueuedPresenceCampaign, + ) + }, + ) } 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..fb7278da26 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 @@ -16,7 +16,8 @@ import { import { electricCollectionOptions } from '../src/electric' import { oraclePropertyOptions, - readOracleRunConfig, + oracleReplayRequested, + oracleReplayTest, } from '../../db/tests/oracle-config' import { managedTagPersistence, @@ -94,8 +95,10 @@ import type { Message } from '@electric-sql/client' // TANSTACK_DB_ORACLE_SEED=20260917 TANSTACK_DB_ORACLE_PATH=0 \ // TANSTACK_DB_ORACLE_PROPERTY=electric.sdk-snapshot-delivery \ // pnpm --dir packages/electric-db-collection exec vitest run tests/electric-sdk-delivery-oracle.property.test.ts -t 'replay' -const { replayPath, replayProperty } = readOracleRunConfig() -const fixedCase = replayPath === undefined ? it : it.skip +// Ordinary tests stand aside during any path replay, which runs one property. +const fixedCase = oracleReplayTest(it) +// TANSTACK_DB_ELECTRIC_SDK_DELIVERY_FAULT is a fault-injection switch, not a +// replay variable. It stays readable during replays. const replayFault = process.env.TANSTACK_DB_ELECTRIC_SDK_DELIVERY_FAULT if ( replayFault !== undefined && @@ -120,9 +123,8 @@ function sdkCampaigns( throw violation.report(error) } } - if (replayPath !== undefined) { - if (replayProperty === propertyId) - it(`${name} (replay)`, () => runCampaign()) + if (oracleReplayRequested()) { + oracleReplayTest(it, propertyId)(`${name} (replay)`, () => runCampaign()) return } it(`${name} (fixed)`, () => runCampaign(fixedSeed)) diff --git a/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts b/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts index 322e254768..6d13fd6ed2 100644 --- a/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts @@ -1,78 +1,44 @@ import { describe, expect, it } from 'vitest' import { fc, test as fcTest } from '@fast-check/vitest' import { serialize } from '../src/pg-serializer' - -// Replay one named law directly with PG_SERIALIZER_ORACLE_PROPERTY=, -// PG_SERIALIZER_ORACLE_SEED=, and -// PG_SERIALIZER_ORACLE_PATH=. -// Example: pnpm exec vitest run tests/pg-serializer-oracle.property.test.ts -t 'mixed finite arrays retain ordered typed contents' -const replayProperty = process.env.PG_SERIALIZER_ORACLE_PROPERTY -const replaySeedText = process.env.PG_SERIALIZER_ORACLE_SEED -const replayPath = process.env.PG_SERIALIZER_ORACLE_PATH -const runsText = process.env.PG_SERIALIZER_ORACLE_RUNS ?? `100` -const runs = Number(runsText) +import { + oraclePropertyOptions, + oracleReplayRequested, + oracleReplayTest, + oracleRuns, +} from '../../db/tests/oracle-config' + +// Replay one named law with TANSTACK_DB_ORACLE_SEED=, +// TANSTACK_DB_ORACLE_PATH=, and +// TANSTACK_DB_ORACLE_PROPERTY=. Example: +// TANSTACK_DB_ORACLE_SEED=1 TANSTACK_DB_ORACLE_PATH=0 \ +// TANSTACK_DB_ORACLE_PROPERTY=pg-serializer.mixed-array-contents \ +// pnpm exec vitest run tests/pg-serializer-oracle.property.test.ts const registeredProperties = new Set() -const example = replayProperty === undefined ? it : it.skip - -if (!Number.isSafeInteger(runs) || runs < 1) { - throw new Error(`PG_SERIALIZER_ORACLE_RUNS must be a positive integer`) -} -if ( - replayProperty === undefined && - (replaySeedText !== undefined || replayPath !== undefined) -) { - throw new Error(`PostgreSQL serializer replay requires a property name`) -} -if ( - replayProperty !== undefined && - (replaySeedText === undefined || replayPath === undefined) -) { - throw new Error(`PostgreSQL serializer replay requires seed and path`) -} -const replaySeed = - replaySeedText === undefined ? undefined : Number(replaySeedText) -if ( - replaySeedText !== undefined && - (replaySeedText.trim() === `` || !Number.isSafeInteger(replaySeed)) -) { - throw new Error(`PG_SERIALIZER_ORACLE_SEED must be an integer`) -} -if (replayPath !== undefined && !/^\d+(?::\d+)*$/.test(replayPath)) { - throw new Error( - `PG_SERIALIZER_ORACLE_PATH must contain colon-separated nonnegative integers`, - ) -} +// Examples stand aside during any path replay, which runs one property. +const example = oracleReplayTest(it) -function serializerReplayOptions( - seed: number | undefined, - path: string | undefined, -) { - return { numRuns: runs, seed, path } -} - -function serializerProp([arbitrary]: [fc.Arbitrary]) { +function serializerProp(property: string, [arbitrary]: [fc.Arbitrary]) { return (name: string, check: (value: T) => void): void => { if (registeredProperties.has(name)) { throw new Error(`duplicate PostgreSQL serializer property: ${name}`) } registeredProperties.add(name) - if (replayProperty === undefined) { - fcTest.prop([arbitrary], { numRuns: runs, seed: 20260930 })( - `${name} (fixed)`, - check, - ) - fcTest.prop([arbitrary], { numRuns: runs })(`${name} (random)`, check) + if (oracleReplayRequested()) { + oracleReplayTest(fcTest, property).prop( + [arbitrary], + oraclePropertyOptions(100, property), + )(`${name} (replay)`, check) return } - if (replayProperty === name) { - fcTest.prop([arbitrary], serializerReplayOptions(replaySeed, replayPath))( - `${name} (replay)`, - check, - ) - } else { - // Vitest requires each describe block to register at least one test. - it.skip(`${name} (not requested)`, () => {}) - } + fcTest.prop([arbitrary], { numRuns: oracleRuns(100), seed: 20260930 })( + `${name} (fixed)`, + check, + ) + fcTest.prop([arbitrary], oraclePropertyOptions(100))( + `${name} (random)`, + check, + ) } } @@ -189,20 +155,26 @@ function expectFiniteArrayContents( describe(`pg-serializer property-based tests`, () => { describe(`string serialization`, () => { - serializerProp([fc.string()])(`strings pass through unchanged`, (str) => { - expect(serialize(str)).toBe(str) - }) + serializerProp(`pg-serializer.string-passthrough`, [fc.string()])( + `strings pass through unchanged`, + (str) => { + expect(serialize(str)).toBe(str) + }, + ) - serializerProp([fc.string()])(`strings are idempotent`, (str) => { - // serialize(serialize(str)) should equal serialize(str) for strings - const once = serialize(str) - const twice = serialize(once) - expect(twice).toBe(once) - }) + serializerProp(`pg-serializer.string-idempotent`, [fc.string()])( + `strings are idempotent`, + (str) => { + // serialize(serialize(str)) should equal serialize(str) for strings + const once = serialize(str) + const twice = serialize(once) + expect(twice).toBe(once) + }, + ) }) describe(`number serialization`, () => { - serializerProp([fc.integer()])( + serializerProp(`pg-serializer.integer-round-trip`, [fc.integer()])( `integers round-trip through strict numeric parsing`, (n) => { const serialized = serialize(n) @@ -210,7 +182,7 @@ describe(`pg-serializer property-based tests`, () => { }, ) - serializerProp([ + serializerProp(`pg-serializer.double-round-trip`, [ fc .double({ noNaN: true, noDefaultInfinity: true }) .filter((n) => !Object.is(n, -0)), @@ -220,14 +192,17 @@ describe(`pg-serializer property-based tests`, () => { expect(parseFiniteNumber(serialized)).toBe(n) }) - serializerProp([fc.integer()])(`integers produce numeric strings`, (n) => { - const serialized = serialize(n) - expect(serialized).toMatch(/^-?\d+$/) - }) + serializerProp(`pg-serializer.integer-text`, [fc.integer()])( + `integers produce numeric strings`, + (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }, + ) }) describe(`bigint serialization`, () => { - serializerProp([fc.bigInt()])( + serializerProp(`pg-serializer.bigint-round-trip`, [fc.bigInt()])( `bigints round-trip through BigInt parsing`, (n) => { const serialized = serialize(n) @@ -235,14 +210,17 @@ describe(`pg-serializer property-based tests`, () => { }, ) - serializerProp([fc.bigInt()])(`bigints produce integer strings`, (n) => { - const serialized = serialize(n) - expect(serialized).toMatch(/^-?\d+$/) - }) + serializerProp(`pg-serializer.bigint-text`, [fc.bigInt()])( + `bigints produce integer strings`, + (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }, + ) }) describe(`boolean serialization`, () => { - serializerProp([fc.boolean()])( + serializerProp(`pg-serializer.boolean-tokens`, [fc.boolean()])( `booleans serialize to 'true' or 'false'`, (b) => { const serialized = serialize(b) @@ -250,7 +228,7 @@ describe(`pg-serializer property-based tests`, () => { }, ) - serializerProp([fc.boolean()])( + serializerProp(`pg-serializer.boolean-round-trip`, [fc.boolean()])( `booleans round-trip through string comparison`, (b) => { const serialized = serialize(b) @@ -260,12 +238,11 @@ describe(`pg-serializer property-based tests`, () => { }) describe(`null/undefined serialization`, () => { - serializerProp([fc.constantFrom(null, undefined)])( - `null and undefined both serialize to empty string`, - (val) => { - expect(serialize(val)).toBe(``) - }, - ) + serializerProp(`pg-serializer.nullish-empty`, [ + fc.constantFrom(null, undefined), + ])(`null and undefined both serialize to empty string`, (val) => { + expect(serialize(val)).toBe(``) + }) }) describe(`date serialization`, () => { @@ -283,7 +260,7 @@ describe(`pg-serializer property-based tests`, () => { expect(serialize(date)).toBe(text) }) - serializerProp([arbitraryBoundedDate])( + serializerProp(`pg-serializer.date-iso`, [arbitraryBoundedDate])( `dates produce valid ISO strings`, (date) => { const serialized = serialize(date) @@ -293,7 +270,7 @@ describe(`pg-serializer property-based tests`, () => { }, ) - serializerProp([arbitraryBoundedDate])( + serializerProp(`pg-serializer.date-round-trip`, [arbitraryBoundedDate])( `dates round-trip through Date parsing`, (date) => { const serialized = serialize(date) @@ -309,32 +286,29 @@ describe(`pg-serializer property-based tests`, () => { expectFiniteArrayContents(input, serialize(input)) }) - serializerProp([fc.array(fc.integer(), { maxLength: 10 })])( - `integer arrays produce Postgres array format`, - (arr) => { - const serialized = serialize(arr) - expect(serialized).toMatch(/^\{.*\}$/) - }, - ) + serializerProp(`pg-serializer.integer-array-format`, [ + fc.array(fc.integer(), { maxLength: 10 }), + ])(`integer arrays produce Postgres array format`, (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + }) - serializerProp([fc.array(fc.integer(), { maxLength: 10 })])( - `integer arrays can be parsed back`, - (arr) => { - const serialized = serialize(arr) - expectFiniteArrayContents(arr, serialized) - }, - ) + serializerProp(`pg-serializer.integer-array-contents`, [ + fc.array(fc.integer(), { maxLength: 10 }), + ])(`integer arrays can be parsed back`, (arr) => { + const serialized = serialize(arr) + expectFiniteArrayContents(arr, serialized) + }) - serializerProp([fc.array(fc.boolean(), { maxLength: 10 })])( - `boolean arrays serialize correctly`, - (arr) => { - const serialized = serialize(arr) - expect(serialized).toMatch(/^\{.*\}$/) - expectFiniteArrayContents(arr, serialized) - }, - ) + serializerProp(`pg-serializer.boolean-array-contents`, [ + fc.array(fc.boolean(), { maxLength: 10 }), + ])(`boolean arrays serialize correctly`, (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + expectFiniteArrayContents(arr, serialized) + }) - serializerProp([ + serializerProp(`pg-serializer.nullish-array-contents`, [ fc.array(fc.constantFrom(null, undefined), { maxLength: 5 }), ])(`arrays with null/undefined serialize to NULL`, (arr) => { const serialized = serialize(arr) @@ -343,16 +317,15 @@ describe(`pg-serializer property-based tests`, () => { }) describe(`string array escaping`, () => { - serializerProp([fc.array(fc.string(), { maxLength: 5 })])( - `string arrays are properly quoted`, - (arr) => { - const serialized = serialize(arr) - expect(serialized).toMatch(/^\{.*\}$/) - expectFiniteArrayContents(arr, serialized) - }, - ) + serializerProp(`pg-serializer.string-array-contents`, [ + fc.array(fc.string(), { maxLength: 5 }), + ])(`string arrays are properly quoted`, (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + expectFiniteArrayContents(arr, serialized) + }) - serializerProp([ + serializerProp(`pg-serializer.string-escaping`, [ fc .tuple(fc.string(), fc.constantFrom(`"`, `\\`), fc.string()) .map((parts) => parts.join(``)), @@ -370,7 +343,7 @@ describe(`pg-serializer property-based tests`, () => { }) describe(`consistency properties`, () => { - serializerProp([ + serializerProp(`pg-serializer.scalar-determinism`, [ fc.oneof( fc.string(), fc.integer(), @@ -384,14 +357,13 @@ describe(`pg-serializer property-based tests`, () => { expect(first).toBe(second) }) - serializerProp([fc.array(fc.integer(), { maxLength: 10 })])( - `array serialization is deterministic`, - (arr) => { - const first = serialize(arr) - const second = serialize(arr) - expect(first).toBe(second) - }, - ) + serializerProp(`pg-serializer.array-determinism`, [ + fc.array(fc.integer(), { maxLength: 10 }), + ])(`array serialization is deterministic`, (arr) => { + const first = serialize(arr) + const second = serialize(arr) + expect(first).toBe(second) + }) }) describe(`finite scalar and array checker calibration`, () => { @@ -526,10 +498,11 @@ describe(`pg-serializer property-based tests`, () => { } expect(failure.errorInstance).toMatchObject({ name: `AssertionError` }) expect(failure.counterexample[0]).toHaveLength(1) - const replay = fc.check( - property, - serializerReplayOptions(failure.seed, failure.counterexamplePath), - ) + const replay = fc.check(property, { + numRuns: oracleRuns(100), + seed: failure.seed, + path: failure.counterexamplePath, + }) expect(replay.failed).toBe(true) expect(replay.counterexample).toEqual(failure.counterexample) expect(replay.errorInstance).toMatchObject({ name: `AssertionError` }) @@ -553,7 +526,7 @@ describe(`pg-serializer property-based tests`, () => { expect(() => parseFiniteArray(output)).toThrow() }) - serializerProp([ + serializerProp(`pg-serializer.mixed-array-contents`, [ fc.array( fc.oneof( fc.string(), diff --git a/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts b/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts index 8b4639cf58..9ba77b9fe7 100644 --- a/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts +++ b/packages/node-db-sqlite-persistence/tests/expression-index-oracle.test.ts @@ -45,10 +45,12 @@ * * Reach, challenge, replay, and cleanup: the expression-index matrix and both * generated BigInt range checks run in fixed-seed and seedless-random campaigns. - * Supplying both TANSTACK_DB_WS5A_SEED and TANSTACK_DB_WS5A_PATH selects only - * the exact replay campaign; TANSTACK_DB_WS5A_PROPERTY selects a BigInt check - * instead of the default expression-index matrix. Either replay value alone - * rejects. Per-axis grammar ablations and same-path SQL/compiler faults must + * A path replay sets TANSTACK_DB_ORACLE_PATH, TANSTACK_DB_ORACLE_SEED, and + * TANSTACK_DB_ORACLE_PROPERTY to one of sqlite-persistence.expression-index, + * sqlite-persistence.bigint-roundtrip, or sqlite-persistence.bigint-rejection. + * That property's random campaign replays only its recorded history; the fixed + * campaigns and the other properties stand aside. A seed alone reseeds the + * random campaigns. Per-axis grammar ablations and same-path SQL/compiler faults must * fail the expression-index property. The * retained fixed campaign reconstructs the known valid matrix. The bounds * above state its range; exact grammar checks reject missing, duplicate, and @@ -70,6 +72,12 @@ import { decodePersistedStorageKey, persistedCollectionOptions, } from '@tanstack/db-sqlite-persistence-core' +import { + oraclePropertyOptions, + oracleReplayTest, + oracleRuns, + readOracleRunConfig, +} from '../../db/tests/oracle-config.js' import { Temporal, temporalFamilies, @@ -133,84 +141,35 @@ const legalPathArbitrary = fc ) .map(([root, tail]) => [root, ...tail]) -type OracleReplayConfiguration = { - seed: number - path: string - numRuns: 1 -} +const EXPRESSION_INDEX_PROPERTY = `sqlite-persistence.expression-index` +const BIGINT_ROUNDTRIP_PROPERTY = `sqlite-persistence.bigint-roundtrip` +const BIGINT_REJECTION_PROPERTY = `sqlite-persistence.bigint-rejection` type GeneratedPropertyCampaign = { - name: `fixed` | `random` | `replay` - seed?: number - path?: string - numRuns: number + name: `fixed` | `random` + parameters: ReturnType } -function oracleReplayConfiguration( - environment: NodeJS.ProcessEnv = process.env, -): OracleReplayConfiguration | undefined { - const seedText = environment.TANSTACK_DB_WS5A_SEED - const path = environment.TANSTACK_DB_WS5A_PATH - if (seedText !== undefined && !/^-?\d+$/.test(seedText)) { - throw new Error(`TANSTACK_DB_WS5A_SEED must be an integer`) - } - if (seedText !== undefined && !Number.isSafeInteger(Number(seedText))) { - throw new Error(`TANSTACK_DB_WS5A_SEED must be an integer`) - } - if (path !== undefined && !/^\d+(?::\d+)*$/.test(path)) { - throw new Error( - `TANSTACK_DB_WS5A_PATH must contain colon-separated nonnegative integers`, - ) - } - if (seedText === undefined && path === undefined) return undefined - if (seedText === undefined) { - throw new Error( - `TANSTACK_DB_WS5A_PATH requires TANSTACK_DB_WS5A_SEED for exact replay`, - ) - } - if (path === undefined) { - throw new Error( - `TANSTACK_DB_WS5A_SEED requires TANSTACK_DB_WS5A_PATH for exact replay`, - ) - } +// The fixed campaign pins its seed and stands aside on every path replay. The +// random campaign runs in ordinary and seed-only runs, and as the replay of +// its property, where oraclePropertyOptions returns the recorded path. +const fixedGeneratedCampaign: GeneratedPropertyCampaign = { + name: `fixed`, + parameters: { + seed: DEFAULT_ORACLE_SEED, + numRuns: oracleRuns(DEFAULT_ORACLE_RUNS), + }, +} +function randomGeneratedCampaign(property: string): GeneratedPropertyCampaign { return { - seed: Number(seedText), - path, - numRuns: 1, + name: `random`, + parameters: oraclePropertyOptions(DEFAULT_ORACLE_RUNS, property), } } -const requestedReplay = oracleReplayConfiguration() -const requestedReplayProperty = - process.env.TANSTACK_DB_WS5A_PROPERTY ?? `expression-index` -if ( - ![`expression-index`, `bigint-roundtrip`, `bigint-rejection`].includes( - requestedReplayProperty, - ) -) { - throw new Error(`TANSTACK_DB_WS5A_PROPERTY must name a generated property`) -} - -const generatedPropertyCampaigns: Array = - requestedReplay === undefined - ? [ - { - name: `fixed`, - seed: DEFAULT_ORACLE_SEED, - numRuns: DEFAULT_ORACLE_RUNS, - }, - { name: `random`, numRuns: DEFAULT_ORACLE_RUNS }, - ] - : [{ name: `replay`, ...requestedReplay }] - function generatedCampaignParameters(campaign: GeneratedPropertyCampaign) { - return { - numRuns: campaign.numRuns, - verbose: 2 as const, - ...(campaign.seed === undefined ? {} : { seed: campaign.seed }), - ...(campaign.path === undefined ? {} : { path: campaign.path }), - } + return { verbose: 2 as const, ...campaign.parameters } } function createNestedRow( @@ -1335,44 +1294,61 @@ async function assertExpressionIndexHistory( } describe(`SQLite expression-index oracle`, () => { - const it = requestedReplay === undefined ? vitestIt : vitestIt.skip + const it = oracleReplayTest(vitestIt) it.each([ { label: `empty seed`, - environment: { TANSTACK_DB_WS5A_SEED: `` }, - message: `TANSTACK_DB_WS5A_SEED must be an integer`, + environment: { TANSTACK_DB_ORACLE_SEED: `` }, + message: `TANSTACK_DB_ORACLE_SEED must be an integer`, }, { - label: `seed without path`, - environment: { TANSTACK_DB_WS5A_SEED: `1659005` }, - message: `TANSTACK_DB_WS5A_SEED requires TANSTACK_DB_WS5A_PATH for exact replay`, + label: `path without seed`, + environment: { TANSTACK_DB_ORACLE_PATH: `0` }, + message: `TANSTACK_DB_ORACLE_PATH requires TANSTACK_DB_ORACLE_SEED`, }, { - label: `path without seed`, - environment: { TANSTACK_DB_WS5A_PATH: `0` }, - message: `TANSTACK_DB_WS5A_PATH requires TANSTACK_DB_WS5A_SEED for exact replay`, + label: `property without path`, + environment: { + TANSTACK_DB_ORACLE_SEED: `1659005`, + TANSTACK_DB_ORACLE_PROPERTY: `sqlite-persistence.expression-index`, + }, + message: `TANSTACK_DB_ORACLE_PROPERTY requires TANSTACK_DB_ORACLE_PATH`, }, { label: `invalid shrink path`, environment: { - TANSTACK_DB_WS5A_SEED: `1659005`, - TANSTACK_DB_WS5A_PATH: `0:`, + TANSTACK_DB_ORACLE_SEED: `1659005`, + TANSTACK_DB_ORACLE_PATH: `0:`, + TANSTACK_DB_ORACLE_PROPERTY: `sqlite-persistence.expression-index`, }, - message: `TANSTACK_DB_WS5A_PATH must contain colon-separated nonnegative integers`, + message: `TANSTACK_DB_ORACLE_PATH must contain colon-separated nonnegative integers`, }, ])(`rejects a $label replay configuration`, ({ environment, message }) => { - expect(() => oracleReplayConfiguration(environment)).toThrow(message) + expect(() => readOracleRunConfig(environment)).toThrow(message) }) - it(`accepts only a checked seed and shrink-path replay pair`, () => { + it(`accepts a seed alone and a checked seed and shrink-path replay pair`, () => { + expect(readOracleRunConfig({ TANSTACK_DB_ORACLE_SEED: `1659005` })).toEqual( + { + multiplier: 1, + replaySeed: 1659005, + replayPath: undefined, + replayProperty: undefined, + }, + ) expect( - oracleReplayConfiguration({ - TANSTACK_DB_WS5A_SEED: `-1659005`, - TANSTACK_DB_WS5A_PATH: `0:1:2`, + readOracleRunConfig({ + TANSTACK_DB_ORACLE_SEED: `-1659005`, + TANSTACK_DB_ORACLE_PATH: `0:1:2`, + TANSTACK_DB_ORACLE_PROPERTY: `sqlite-persistence.expression-index`, }), - ).toEqual({ seed: -1659005, path: `0:1:2`, numRuns: 1 }) - expect(oracleReplayConfiguration({})).toBeUndefined() + ).toEqual({ + multiplier: 1, + replaySeed: -1659005, + replayPath: `0:1:2`, + replayProperty: `sqlite-persistence.expression-index`, + }) }) it.each([ @@ -2912,112 +2888,114 @@ describe(`SQLite expression-index oracle`, () => { }, [() => driver.close()]) }) - const generatedBigIntRoundTripTest = - requestedReplay === undefined || - requestedReplayProperty === `bigint-roundtrip` - ? vitestIt - : vitestIt.skip + async function roundTripGeneratedBigInt(campaign: GeneratedPropertyCampaign) { + await fc.assert( + fc.asyncProperty( + fc.bigInt({ min: SQLITE_BIGINT_MIN, max: SQLITE_BIGINT_MAX }), + async (value) => { + const driver = new BetterSqlite3SQLiteDriver({ + filename: `:memory:`, + }) + const adapter = createSQLiteCorePersistenceAdapter({ driver }) + const collectionId = `generated-bigint-in-range` - generatedBigIntRoundTripTest.each(generatedPropertyCampaigns)( - `round-trips generated signed-64-bit BigInts through the real adapter in the $name campaign`, - async (campaign) => { - await fc.assert( - fc.asyncProperty( - fc.bigInt({ min: SQLITE_BIGINT_MIN, max: SQLITE_BIGINT_MAX }), - async (value) => { - const driver = new BetterSqlite3SQLiteDriver({ - filename: `:memory:`, - }) - const adapter = createSQLiteCorePersistenceAdapter({ driver }) - const collectionId = `generated-bigint-in-range` - - await withFailurePreservingCleanup(async () => { - await adapter.applyCommittedTx( - { kind: `eager`, collectionId: collectionId }, - { - txId: `seed-${value}`, - term: 1, - seq: 1, - rowVersion: 1, - mutations: [ - { - type: `insert`, - key: `match`, - value: { nested: { count: value } }, - }, - ], - }, - ) + await withFailurePreservingCleanup(async () => { + await adapter.applyCommittedTx( + { kind: `eager`, collectionId: collectionId }, + { + txId: `seed-${value}`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `match`, + value: { nested: { count: value } }, + }, + ], + }, + ) - const rows = await adapter.loadSubset( - { kind: `eager`, collectionId: collectionId }, - { - where: new IR.Func(`eq`, [ - new IR.PropRef([`nested`, `count`]), - new IR.Value(value), - ]), - }, - ) - expect(rows.map((row) => row.key)).toEqual([`match`]) - expect(rows[0]?.value).toEqual({ nested: { count: value } }) - }, [() => driver.close()]) - }, - ), - generatedCampaignParameters(campaign), - ) - }, + const rows = await adapter.loadSubset( + { kind: `eager`, collectionId: collectionId }, + { + where: new IR.Func(`eq`, [ + new IR.PropRef([`nested`, `count`]), + new IR.Value(value), + ]), + }, + ) + expect(rows.map((row) => row.key)).toEqual([`match`]) + expect(rows[0]?.value).toEqual({ nested: { count: value } }) + }, [() => driver.close()]) + }, + ), + generatedCampaignParameters(campaign), + ) + } + oracleReplayTest(vitestIt).each([fixedGeneratedCampaign])( + `round-trips generated signed-64-bit BigInts through the real adapter in the $name campaign`, + roundTripGeneratedBigInt, + ) + oracleReplayTest(vitestIt, BIGINT_ROUNDTRIP_PROPERTY).each([ + randomGeneratedCampaign(BIGINT_ROUNDTRIP_PROPERTY), + ])( + `round-trips generated signed-64-bit BigInts through the real adapter in the $name campaign`, + roundTripGeneratedBigInt, ) - const generatedBigIntRejectionTest = - requestedReplay === undefined || - requestedReplayProperty === `bigint-rejection` - ? vitestIt - : vitestIt.skip - - generatedBigIntRejectionTest.each(generatedPropertyCampaigns)( - `rejects generated BigInts immediately outside the signed range in the $name campaign`, - async (campaign) => { - const outOfRangeBigIntArbitrary = fc.oneof( - fc - .bigInt({ min: 1n, max: 1_000n }) - .map((distance) => SQLITE_BIGINT_MIN - distance), - fc - .bigInt({ min: 1n, max: 1_000n }) - .map((distance) => SQLITE_BIGINT_MAX + distance), - ) + async function rejectGeneratedBigInt(campaign: GeneratedPropertyCampaign) { + const outOfRangeBigIntArbitrary = fc.oneof( + fc + .bigInt({ min: 1n, max: 1_000n }) + .map((distance) => SQLITE_BIGINT_MIN - distance), + fc + .bigInt({ min: 1n, max: 1_000n }) + .map((distance) => SQLITE_BIGINT_MAX + distance), + ) - await fc.assert( - fc.asyncProperty(outOfRangeBigIntArbitrary, async (value) => { - const driver = new BetterSqlite3SQLiteDriver({ filename: `:memory:` }) - const adapter = createSQLiteCorePersistenceAdapter({ driver }) + await fc.assert( + fc.asyncProperty(outOfRangeBigIntArbitrary, async (value) => { + const driver = new BetterSqlite3SQLiteDriver({ filename: `:memory:` }) + const adapter = createSQLiteCorePersistenceAdapter({ driver }) - await withFailurePreservingCleanup(async () => { - await expect( - adapter.applyCommittedTx( - { - kind: `eager`, - collectionId: `generated-bigint-out-of-range`, - }, - { - txId: `reject-${value}`, - term: 1, - seq: 1, - rowVersion: 1, - mutations: [ - { - type: `insert`, - key: `rejected`, - value: { nested: { count: value } }, - }, - ], - }, - ), - ).rejects.toThrow(bigintRangeError(value)) - }, [() => driver.close()]) - }), - generatedCampaignParameters(campaign), - ) - }, + await withFailurePreservingCleanup(async () => { + await expect( + adapter.applyCommittedTx( + { + kind: `eager`, + collectionId: `generated-bigint-out-of-range`, + }, + { + txId: `reject-${value}`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `insert`, + key: `rejected`, + value: { nested: { count: value } }, + }, + ], + }, + ), + ).rejects.toThrow(bigintRangeError(value)) + }, [() => driver.close()]) + }), + generatedCampaignParameters(campaign), + ) + } + oracleReplayTest(vitestIt).each([fixedGeneratedCampaign])( + `rejects generated BigInts immediately outside the signed range in the $name campaign`, + rejectGeneratedBigInt, + ) + oracleReplayTest(vitestIt, BIGINT_REJECTION_PROPERTY).each([ + randomGeneratedCampaign(BIGINT_REJECTION_PROPERTY), + ])( + `rejects generated BigInts immediately outside the signed range in the $name campaign`, + rejectGeneratedBigInt, ) it(`does not confuse an alias-qualified field ref with a nested JSON path`, async () => { @@ -3252,33 +3230,35 @@ describe(`SQLite expression-index oracle`, () => { ) }) - const generatedExpressionIndexTest = - requestedReplay === undefined || - requestedReplayProperty === `expression-index` - ? vitestIt - : vitestIt.skip - - generatedExpressionIndexTest.each(generatedPropertyCampaigns)( + async function reachGeneratedExpressionIndex( + campaign: GeneratedPropertyCampaign, + ) { + const details = await runGeneratedScenarioProperty(campaign) + const parameters = generatedCampaignParameters(campaign) + + expect(details.numRuns).toBe(campaign.parameters.numRuns) + expect(`seed` in parameters).toBe(campaign.parameters.seed !== undefined) + expect(`path` in parameters).toBe(campaign.parameters.path !== undefined) + if (campaign.parameters.seed !== undefined) { + expect(details.seed).toBe(campaign.parameters.seed) + } + if (campaign.parameters.path !== undefined) { + expect(details.runConfiguration.path).toBe(campaign.parameters.path) + } + expect(details.seed).toEqual(expect.any(Number)) + } + oracleReplayTest(vitestIt).each([fixedGeneratedCampaign])( `reaches every declared generated expression-index scenario in the $name campaign`, - async (campaign) => { - const details = await runGeneratedScenarioProperty(campaign) - const parameters = generatedCampaignParameters(campaign) - - expect(details.numRuns).toBe(campaign.numRuns) - expect(`seed` in parameters).toBe(campaign.seed !== undefined) - expect(`path` in parameters).toBe(campaign.path !== undefined) - if (campaign.seed !== undefined) { - expect(details.seed).toBe(campaign.seed) - } - if (campaign.path !== undefined) { - expect(details.runConfiguration.path).toBe(campaign.path) - } - expect(details.seed).toEqual(expect.any(Number)) - }, + reachGeneratedExpressionIndex, + ) + oracleReplayTest(vitestIt, EXPRESSION_INDEX_PROPERTY).each([ + randomGeneratedCampaign(EXPRESSION_INDEX_PROPERTY), + ])( + `reaches every declared generated expression-index scenario in the $name campaign`, + reachGeneratedExpressionIndex, ) - const generatedControlTest = - requestedReplay === undefined ? vitestIt : vitestIt.skip + const generatedControlTest = oracleReplayTest(vitestIt) generatedControlTest.each(GENERATED_SCENARIO_KINDS)( `rejects the generated grammar when the %s axis is ablated`, @@ -3349,22 +3329,21 @@ describe(`SQLite expression-index oracle`, () => { expect(details.error ?? ``, evidence).not.toContain(`did not reach`) expect(details.error, evidence).toContain(`"kind": "${kind}"`) - const replayPath = details.counterexamplePath - if (replayPath === null) { + if (details.counterexamplePath === null) { throw new Error(`${kind} wrong-answer control did not shrink to a path`) } const replay = await fc.check( generatedScenarioProperty({ faultKind: kind }), { seed: details.seed, - path: replayPath, + path: details.counterexamplePath, numRuns: 1, verbose: 2, }, ) const replayEvidence = JSON.stringify({ seed: replay.seed, - requestedPath: replayPath, + requestedPath: details.counterexamplePath, replayPath: replay.counterexamplePath, error: replay.error, }) @@ -3378,7 +3357,7 @@ describe(`SQLite expression-index oracle`, () => { // Native values add a bounded rank/text dimension to this SQL/plan owner. // The common model is independent; this driver observes real SQL before cleanup. describe(`Temporal expression-index refinement`, () => { - const it = requestedReplay === undefined ? vitestIt : vitestIt.skip + const it = oracleReplayTest(vitestIt) it.each(temporalFamilies)( `uses native keys and identity for $kind / $name`, async (family) => { diff --git a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts index 5fd1e581af..2507650f6b 100644 --- a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts @@ -1,9 +1,15 @@ import { fc } from '@fast-check/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' import { KeyScheduler } from '../src/executor/KeyScheduler' -import { readOfflineOracleConfig } from './oracle-config' +import { + oraclePropertyOptions, + oracleReplayTest, + oracleRuns, +} from '../../db/tests/oracle-config' import type { OfflineTransaction } from '../src/types' +const FIFO_LEDGER_PROPERTY = `offline.key-scheduler-fifo-ledger` + type Command = | { type: `schedule` @@ -57,14 +63,7 @@ type Snapshot = { const BASE_TIME = Date.parse(`2026-01-01T00:00:00.000Z`) const FIXED_SEED = 1815 -const { - runs: RUNS, - seed: SEED, - path: PATH, -} = readOfflineOracleConfig({ - prefix: `TANSTACK_DB_OFFLINE_ORACLE`, - defaultRuns: 150, -}) +const RUNS = oracleRuns(150) /** * # Which offline transaction may run next? @@ -103,8 +102,9 @@ const { * histories cross retry-deadline order. Five observation mutants prove * the comparison rejects bypass, double issue, stale payload, stale clear, and * failed selective retirement. The generated property runs at fixed seed 1815 - * and at a random seed with the same budget. Set TANSTACK_DB_OFFLINE_ORACLE_SEED - * and TANSTACK_DB_OFFLINE_ORACLE_PATH to replay a failure directly. + * and at a random seed with the same budget. Set TANSTACK_DB_ORACLE_SEED, + * TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY to replay a failure + * directly. * * Limits: persistence, caller promise settlement, leadership, and real timers * have separate owners. The model does not promise fairness beyond FIFO order. @@ -517,32 +517,32 @@ function runHistory( describe(`KeyScheduler generated lifecycle`, () => { afterEach(() => vi.useRealTimers()) - function runGeneratedHistory(seed?: number, path?: string): void { - fc.assert( - fc.property(legalHistory, (history) => { - runHistory(history) - }), - { - ...(seed === undefined ? {} : { seed }), - numRuns: RUNS, - ...(path === undefined ? {} : { path }), - }, - ) - } - - if (SEED === undefined) { - it(`matches the FIFO retry ledger at the fixed seed`, () => { - runGeneratedHistory(FIXED_SEED) - }) + // The fixed campaign stands aside during any path replay. The random + // campaign is the replay of FIFO_LEDGER_PROPERTY: it reseeds under a seed + // alone and runs the recorded path under a path replay of that property. + oracleReplayTest(it)( + `matches the FIFO retry ledger at the fixed seed`, + () => { + fc.assert( + fc.property(legalHistory, (history) => { + runHistory(history) + }), + { seed: FIXED_SEED, numRuns: RUNS }, + ) + }, + ) - it(`matches the FIFO retry ledger at a random seed`, () => { - runGeneratedHistory() - }) - } else { - it(`replays the FIFO retry ledger at the requested seed and path`, () => { - runGeneratedHistory(SEED, PATH) - }) - } + oracleReplayTest(it, FIFO_LEDGER_PROPERTY)( + `matches the FIFO retry ledger at a random or replayed seed`, + () => { + fc.assert( + fc.property(legalHistory, (history) => { + runHistory(history) + }), + oraclePropertyOptions(150, FIFO_LEDGER_PROPERTY), + ) + }, + ) it(`reconstructs active removal with a waiting sibling`, () => { const token = (selector: number, slot = 0): CommandToken => ({ diff --git a/packages/offline-transactions/tsconfig.json b/packages/offline-transactions/tsconfig.json index 648131b1ea..88e1750367 100644 --- a/packages/offline-transactions/tsconfig.json +++ b/packages/offline-transactions/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "." + "rootDir": "../.." }, "include": ["src/**/*", "tests/**/*", "*.ts"], "exclude": ["dist", "node_modules"] diff --git a/packages/powersync-db-collection/tests/correctness-oracle.test.ts b/packages/powersync-db-collection/tests/correctness-oracle.test.ts index a974b7b781..a378af3dff 100644 --- a/packages/powersync-db-collection/tests/correctness-oracle.test.ts +++ b/packages/powersync-db-collection/tests/correctness-oracle.test.ts @@ -48,9 +48,12 @@ import { TEST_DATABASE_IMPLEMENTATION } from './test-db-implementation' import type { WatchOnChangeHandler } from '@powersync/common' import type { PowerSyncLogger } from '@powersync/node' -const describePowerSync = TEST_DATABASE_IMPLEMENTATION - ? describe - : describe.skip +// Without a SQLite binding the oracle would silently not run, so fail loudly. +if (TEST_DATABASE_IMPLEMENTATION === undefined) { + throw new Error( + `PowerSync correctness oracle needs a SQLite binding: install better-sqlite3, or run on Node 24+ where node:sqlite is available`, + ) +} type CrudRow = { data: string } @@ -137,7 +140,7 @@ async function cleanupAfterOracle( } } -describePowerSync(`PowerSync correctness oracle`, () => { +describe(`PowerSync correctness oracle`, () => { afterEach(() => vi.restoreAllMocks()) it(`rejects stale full-row patches and undeclared public keys`, () => { diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts index 78d2a099c7..591ce9f3af 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts @@ -3,6 +3,12 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fc } from '@fast-check/vitest' import { afterEach, expect, it } from 'vitest' +import { + oracleRandomParameters, + oracleReplayTest, + oracleRuns, + readOracleRunConfig, +} from '../../db/tests/oracle-config.js' import { OpSQLiteDriver } from '../src/op-sqlite-driver' import { InvalidPersistedCollectionConfigError } from '../../db-sqlite-persistence-core/src' import { runSQLiteDriverContractSuite } from '../../db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract' @@ -133,7 +139,8 @@ async function withColumnarDriver( * adapter. Named empty, reordered, duplicated, missing-key, and swapped-value * wrong answers challenge the checker. Normal execution runs the same alias * properties and budgets in retained fixed-seed and seedless-random lanes. - * Seed, numeric shrink path, and property coordinates select one exact replay. + * TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY + * select one exact replay. * Real-provider refinement: frozen receipts from the published 15.2.7 Native * and Node sources, plus forward receipts from 18.2.1 Native, Node, and browser * sources, prove the shim's accepted envelopes match provider output. @@ -503,127 +510,61 @@ const writeResultFieldNames = [ `lastInsertRowId`, ] as const -const aliasOracleProperties = [ - `write-marker-aliases`, - `result-carrier-equivalence`, - `columnar-sql-aliases`, -] as const - -type AliasOracleProperty = (typeof aliasOracleProperties)[number] +type AliasOracleProperty = + | `op-sqlite.write-marker-aliases` + | `op-sqlite.result-carrier-equivalence` + | `op-sqlite.columnar-sql-aliases` type AliasOracleEnvironment = Record -type AliasOracleConfig = { - runs: number - replaySeed: number | undefined - replayPath: string | undefined - replayProperty: AliasOracleProperty | undefined -} -type AliasOracleCampaign = { - name: `fixed` | `random` | `replay` +type AliasOracleParameters = { numRuns: number - seed: number | undefined - path: string | undefined + seed?: number + path?: string } const FIXED_ALIAS_ORACLE_SEED = 165903 -function isAliasOracleProperty(value: string): value is AliasOracleProperty { - return aliasOracleProperties.some((property) => property === value) -} - -function readAliasOracleConfig( +function readAliasOracleRuns( environment: AliasOracleEnvironment = process.env, -): AliasOracleConfig { - const runsText = environment.TANSTACK_DB_OP_SQLITE_ORACLE_RUNS ?? `50` +): number { + // A run-count control, not a replay coordinate. Its default is the shared + // base count, so TANSTACK_DB_ORACLE_RUNS_MULTIPLIER still scales it. + const runsText = + environment.TANSTACK_DB_OP_SQLITE_ORACLE_RUNS ?? String(oracleRuns(50)) const runs = Number(runsText) if (runsText.trim() === `` || !Number.isSafeInteger(runs) || runs < 1) { throw new Error( `TANSTACK_DB_OP_SQLITE_ORACLE_RUNS must be a positive integer`, ) } - - const seedText = environment.TANSTACK_DB_OP_SQLITE_ORACLE_SEED - const path = environment.TANSTACK_DB_OP_SQLITE_ORACLE_PATH - const propertyText = environment.TANSTACK_DB_OP_SQLITE_ORACLE_PROPERTY - if ( - seedText === undefined && - path === undefined && - propertyText === undefined - ) { - return { - runs, - replaySeed: undefined, - replayPath: undefined, - replayProperty: undefined, - } - } - - if (seedText === undefined) { - throw new Error( - `TANSTACK_DB_OP_SQLITE_ORACLE_PATH and PROPERTY require SEED`, - ) - } - const replaySeed = Number(seedText) - if (seedText.trim() === `` || !Number.isSafeInteger(replaySeed)) { - throw new Error(`TANSTACK_DB_OP_SQLITE_ORACLE_SEED must be an integer`) - } - if (path === undefined || !/^\d+(?::\d+)*$/.test(path)) { - throw new Error( - `TANSTACK_DB_OP_SQLITE_ORACLE_PATH must be a numeric shrink path`, - ) - } - if (propertyText === undefined || !isAliasOracleProperty(propertyText)) { - throw new Error( - `TANSTACK_DB_OP_SQLITE_ORACLE_PROPERTY must name a generated property`, - ) - } - - return { - runs, - replaySeed, - replayPath: path, - replayProperty: propertyText, - } + return runs } -function aliasOracleCampaigns( - property: AliasOracleProperty, - config: AliasOracleConfig, -): ReadonlyArray { - if (config.replayProperty !== undefined) { - if (config.replayProperty !== property) return [] - return [ - { - name: `replay`, - numRuns: 1, - seed: config.replaySeed, - path: config.replayPath, - }, - ] - } +const aliasOracleRuns = readAliasOracleRuns() +// Replay coordinates come from the shared TANSTACK_DB_ORACLE_* variables. +const aliasOracleReplay = readOracleRunConfig() - return [ - { - name: `fixed`, - numRuns: config.runs, - seed: FIXED_ALIAS_ORACLE_SEED, - path: undefined, - }, - { - name: `random`, - numRuns: config.runs, - seed: undefined, - path: undefined, - }, - ] -} - -function aliasOracleParameters(campaign: AliasOracleCampaign) { - return { - numRuns: campaign.numRuns, - ...(campaign.seed === undefined ? {} : { seed: campaign.seed }), - ...(campaign.path === undefined ? {} : { path: campaign.path }), - ...(campaign.name === `replay` ? { endOnFailure: true } : {}), - } +/** + * Registers a property's two campaigns. The fixed campaign pins its seed and + * stands aside on every path replay. The random campaign runs in ordinary and + * seed-only runs, and as the replay of its property, where the shared helper + * returns the recorded path. + */ +function registerAliasOracle( + property: AliasOracleProperty, + title: string, + check: (parameters: AliasOracleParameters) => Promise, +): void { + const randomParameters = oracleRandomParameters( + aliasOracleRuns, + aliasOracleReplay, + property, + ) + oracleReplayTest(it)(`${title} [fixed]`, () => + check({ numRuns: aliasOracleRuns, seed: FIXED_ALIAS_ORACLE_SEED }), + ) + oracleReplayTest(it, property)(`${title} [random]`, () => + check(randomParameters), + ) } const writeResultAliasesArbitrary = fc.uniqueArray( @@ -695,99 +636,88 @@ function expectAliasGrammar( } it(`configures identical fixed and seedless-random alias campaigns`, () => { - const config = readAliasOracleConfig({}) - expect(aliasOracleCampaigns(`write-marker-aliases`, config)).toEqual([ - { - name: `fixed`, - numRuns: 50, - seed: FIXED_ALIAS_ORACLE_SEED, - path: undefined, - }, - { - name: `random`, - numRuns: 50, - seed: undefined, - path: undefined, - }, - ]) + const runs = readAliasOracleRuns({}) + expect(runs).toBe(oracleRuns(50)) + expect( + oracleRandomParameters(runs, undefined, `op-sqlite.write-marker-aliases`), + ).toEqual({ numRuns: runs }) +}) + +it(`reseeds the random alias campaigns from a seed-only replay`, () => { + expect( + oracleRandomParameters( + 50, + readOracleRunConfig({ TANSTACK_DB_ORACLE_SEED: `271828` }), + `op-sqlite.write-marker-aliases`, + ), + ).toEqual({ numRuns: 50, seed: 271828 }) }) it(`selects exactly one alias property and shrink path for replay`, () => { - const config = readAliasOracleConfig({ - TANSTACK_DB_OP_SQLITE_ORACLE_SEED: `271828`, - TANSTACK_DB_OP_SQLITE_ORACLE_PATH: `0:1`, - TANSTACK_DB_OP_SQLITE_ORACLE_PROPERTY: `columnar-sql-aliases`, + const replay = readOracleRunConfig({ + TANSTACK_DB_ORACLE_SEED: `271828`, + TANSTACK_DB_ORACLE_PATH: `0:1`, + TANSTACK_DB_ORACLE_PROPERTY: `op-sqlite.columnar-sql-aliases`, }) - expect(aliasOracleCampaigns(`write-marker-aliases`, config)).toEqual([]) - expect(aliasOracleCampaigns(`result-carrier-equivalence`, config)).toEqual([]) - const [replay] = aliasOracleCampaigns(`columnar-sql-aliases`, config) - expect(replay).toEqual({ - name: `replay`, - numRuns: 1, - seed: 271828, - path: `0:1`, - }) - expect(aliasOracleParameters(replay!)).toEqual({ - numRuns: 1, - seed: 271828, - path: `0:1`, - endOnFailure: true, - }) + expect( + oracleRandomParameters(50, replay, `op-sqlite.write-marker-aliases`), + ).toEqual({ numRuns: 50, seed: 271828 }) + expect( + oracleRandomParameters(50, replay, `op-sqlite.columnar-sql-aliases`), + ).toMatchObject({ numRuns: 1, seed: 271828, path: `0:1` }) }) it.each([ { name: `path without seed`, - environment: { TANSTACK_DB_OP_SQLITE_ORACLE_PATH: `0` }, + environment: { TANSTACK_DB_ORACLE_PATH: `0` }, }, { name: `property without seed`, environment: { - TANSTACK_DB_OP_SQLITE_ORACLE_PROPERTY: `write-marker-aliases`, + TANSTACK_DB_ORACLE_PROPERTY: `op-sqlite.write-marker-aliases`, }, }, - { - name: `seed without path`, - environment: { TANSTACK_DB_OP_SQLITE_ORACLE_SEED: `1` }, - }, { name: `seed and path without property`, environment: { - TANSTACK_DB_OP_SQLITE_ORACLE_SEED: `1`, - TANSTACK_DB_OP_SQLITE_ORACLE_PATH: `0`, + TANSTACK_DB_ORACLE_SEED: `1`, + TANSTACK_DB_ORACLE_PATH: `0`, }, }, { name: `non-integer seed`, environment: { - TANSTACK_DB_OP_SQLITE_ORACLE_SEED: `1.5`, - TANSTACK_DB_OP_SQLITE_ORACLE_PATH: `0`, - TANSTACK_DB_OP_SQLITE_ORACLE_PROPERTY: `write-marker-aliases`, + TANSTACK_DB_ORACLE_SEED: `1.5`, + TANSTACK_DB_ORACLE_PATH: `0`, + TANSTACK_DB_ORACLE_PROPERTY: `op-sqlite.write-marker-aliases`, }, }, - { - name: `non-integer run budget`, - environment: { TANSTACK_DB_OP_SQLITE_ORACLE_RUNS: `1.5` }, - }, { name: `invalid shrink path`, environment: { - TANSTACK_DB_OP_SQLITE_ORACLE_SEED: `1`, - TANSTACK_DB_OP_SQLITE_ORACLE_PATH: `first`, - TANSTACK_DB_OP_SQLITE_ORACLE_PROPERTY: `write-marker-aliases`, + TANSTACK_DB_ORACLE_SEED: `1`, + TANSTACK_DB_ORACLE_PATH: `first`, + TANSTACK_DB_ORACLE_PROPERTY: `op-sqlite.write-marker-aliases`, }, }, { name: `unknown property`, environment: { - TANSTACK_DB_OP_SQLITE_ORACLE_SEED: `1`, - TANSTACK_DB_OP_SQLITE_ORACLE_PATH: `0`, - TANSTACK_DB_OP_SQLITE_ORACLE_PROPERTY: `unknown`, + TANSTACK_DB_ORACLE_SEED: `1`, + TANSTACK_DB_ORACLE_PATH: `0`, + TANSTACK_DB_ORACLE_PROPERTY: `unknown`, }, }, ])(`rejects incomplete alias replay coordinates: $name`, ({ environment }) => { - expect(() => readAliasOracleConfig(environment)).toThrow() + expect(() => readOracleRunConfig(environment)).toThrow() +}) + +it(`rejects a non-integer alias run budget`, () => { + expect(() => + readAliasOracleRuns({ TANSTACK_DB_OP_SQLITE_ORACLE_RUNS: `1.5` }), + ).toThrow(`TANSTACK_DB_OP_SQLITE_ORACLE_RUNS must be a positive integer`) }) it(`controls alias grammar reconstruction, ablation, range, and exclusion`, () => { @@ -795,13 +725,10 @@ it(`controls alias grammar reconstruction, ablation, range, and exclusion`, () = expectAliasGrammar(statementResultAliasesArbitrary, statementResultAliasNames) }) -const aliasOracleConfig = readAliasOracleConfig() - -for (const campaign of aliasOracleCampaigns( - `write-marker-aliases`, - aliasOracleConfig, -)) { - it(`preserves generated single-row write-marker aliases in direct row arrays [${campaign.name}]`, async () => { +registerAliasOracle( + `op-sqlite.write-marker-aliases`, + `preserves generated single-row write-marker aliases in direct row arrays`, + async (parameters) => { await fc.assert( fc.asyncProperty(writeResultAliasesArbitrary, async (aliases) => { const row = Object.fromEntries( @@ -811,22 +738,19 @@ for (const campaign of aliasOracleCampaigns( expectExactAliasRows(actual, [row]) }), { - ...aliasOracleParameters(campaign), - ...(campaign.name === `replay` - ? {} - : { - examples: [[[`rowsAffected`]], [[...writeResultFieldNames]]], - }), + ...parameters, + ...(parameters.path === undefined + ? { examples: [[[`rowsAffected`]], [[...writeResultFieldNames]]] } + : { endOnFailure: true }), }, ) - }) -} + }, +) -for (const campaign of aliasOracleCampaigns( - `result-carrier-equivalence`, - aliasOracleConfig, -)) { - it(`returns the same rows across equivalent documented result carriers [${campaign.name}]`, async () => { +registerAliasOracle( + `op-sqlite.result-carrier-equivalence`, + `returns the same rows across equivalent documented result carriers`, + async (parameters) => { await fc.assert( fc.asyncProperty(statementResultAliasesArbitrary, async (aliases) => { const { expected } = aliasQueryCase(aliases) @@ -872,19 +796,19 @@ for (const campaign of aliasOracleCampaigns( } }), { - ...aliasOracleParameters(campaign), - ...(campaign.name === `replay` - ? {} - : { + ...parameters, + ...(parameters.path === undefined + ? { examples: [ [[`rowsAffected`]], [[`rows`, `rawRows`, `columnNames`, `ordinary_name`]], ], - }), + } + : { endOnFailure: true }), }, ) - }) -} + }, +) function quoteIdentifier(identifier: string): string { return `"${identifier.replaceAll(`"`, `""`)}"` @@ -965,11 +889,10 @@ it(`preserves statement-result field names in direct row arrays`, async () => { ) }) -for (const campaign of aliasOracleCampaigns( - `columnar-sql-aliases`, - aliasOracleConfig, -)) { - it(`preserves generated legal SQL aliases through v14 columnar rows [${campaign.name}]`, async () => { +registerAliasOracle( + `op-sqlite.columnar-sql-aliases`, + `preserves generated legal SQL aliases through v14 columnar rows`, + async (parameters) => { await withColumnarDriver(async ({ driver, queryExecutions }) => { await fc.assert( fc.asyncProperty(statementResultAliasesArbitrary, async (aliases) => { @@ -986,15 +909,15 @@ for (const campaign of aliasOracleCampaigns( expectExactAliasRows(actual, expected) }), { - ...aliasOracleParameters(campaign), - ...(campaign.name === `replay` - ? {} - : { examples: [[[...statementResultFieldNames]]] }), + ...parameters, + ...(parameters.path === undefined + ? { examples: [[[...statementResultFieldNames]]] } + : { endOnFailure: true }), }, ) }) - }) -} + }, +) it(`returns an exact empty row set for an empty columnar SELECT`, async () => { await withColumnarDriver(async ({ driver, queryExecutions }) => { From be4077732830be508e50163dea700ff4711cfd70 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 23 Oct 2026 15:46:11 -0600 Subject: [PATCH 7/8] fix(oracles): close rule bypasses and replay gaps from review Address the medium code review and CodeRabbit's review of 95c16bb4d: - oracle/no-disabled checks a registration made through oracleReplayTest or oracleCalibrationTest as a registration of its test, so `.only`, `.skip`, `.skipIf`, and guards through the helper are reported. - A guard is accepted only when the test registers while the replay helper is false: under `!helper()`, in the `else` of `helper()`, or after `if (helper()) return`. A test that registers only during a replay is reported, and so is `if (!helper()) return`. - `continue` and `break` filter a loop's data, so they guard nothing; the two loop rewrites they forced are reverted. - The numRuns exemption matches only packages/db/tests/oracle-config.ts. - Replay-only lanes in the Electric, pg-serializer, SDK delivery, descriptor isolation, and includes-optimistic oracles become fixed and random campaigns through the shared helpers. - oracleCalibrationTest skips a calibration witness during a replay without its fault, where it used to pass without checking anything. - readOracleRunConfig rejects retired replay variables, so an old replay command fails instead of running the full campaigns. - The interruption-settlement oracle uses the shared replay variables, and the offline config no longer accepts TANSTACK_DB_OFFLINE_ORACLE_*. - The replay manifest names owners for the new replay properties, and its owner check covers the numbered utils and comparison families. - Fixed-campaign run counts scale with the multiplier in the queued-presence, binding-capacity, and IndexedDB cross-tab campaigns. - Comments and the coverage map describe the shared replay variables. Refs #2234 Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/sqlite-core-adapter-oracle.test.ts | 3 +- .../tests/comparison-oracle.property.test.ts | 6 +- .../cross-tab-history-oracle.test.ts | 14 ++- ...age-value-fidelity-oracle.property.test.ts | 5 +- packages/db/tests/oracle-config.ts | 73 ++++++++++++ packages/db/tests/oracle-replay-manifest.ts | 54 +++++++++ packages/db/tests/oracle-replay.test.ts | 4 + ...-cross-formulation-oracle.property.test.ts | 23 ++-- ...ncludes-optimistic-oracle.property.test.ts | 22 ++-- .../query/includes-oracle.property.test.ts | 29 ++--- .../db/tests/utils-oracle.property.test.ts | 10 +- ...ectric-descriptor-isolation-oracle.test.ts | 75 +++++------- .../tests/electric-oracle.property.test.ts | 107 +++++++----------- ...ctric-sdk-delivery-oracle.property.test.ts | 11 +- .../pg-serializer-oracle.property.test.ts | 26 ++--- ...ruption-settlement-oracle.property.test.ts | 54 ++++----- .../tests/oracle-config.ts | 2 +- .../eslint-plugin-oracle/no-disabled.test.js | 39 ++++++- .../eslint-plugin-oracle/rules/no-disabled.js | 104 +++++++++++------ 20 files changed, 400 insertions(+), 263 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 30653cc742..19123b7418 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -410,7 +410,7 @@ does not award oracle credit for a filename alone. | [#1833](https://github.com/TanStack/db/pull/1833) | Explicit oracle | `packages/db/tests/query/pagination-oracle.property.test.ts` owns inherited collection collation, actual `item2`/`item10` order, exact request options, hostile lexical/numeric controls, and both scan and auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | | [#1834](https://github.com/TanStack/db/pull/1834) | Explicit oracle | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` owns join/predicate equality equivalence across the established value domains, binary/string and nullish controls, replacement histories, raw lazy demand, and scan/auto-index paths. It runs in `@tanstack/db`'s `test:oracles` campaign. | | [#1835](https://github.com/TanStack/db/pull/1835) | Explicit oracle | The existing `packages/db/tests/collection-state-retention-oracle.property.test.ts` and `packages/db/tests/optimistic-transaction-oracle.property.test.ts` owners cover separate collection-state and transaction-history laws. Both were already registered in `@tanstack/db`'s `test:oracles` campaign; focused storage/local-only tests remain collateral. | -| [#1837](https://github.com/TanStack/db/pull/1837) | Explicit oracle | The offline scheduler, leadership replay, and serializer owners cover selective replay retirement, durable per-ID settlement, stale-read fencing, lifecycle recovery, native scalar encoding, and prior wire compatibility. They run in the package test campaign; generated owners expose `OFFLINE_ORACLE_{SEED,PATH,RUNS}` or the scheduler's `TANSTACK_DB_OFFLINE_ORACLE_*` replay interface. | +| [#1837](https://github.com/TanStack/db/pull/1837) | Explicit oracle | The offline scheduler, leadership replay, and serializer owners cover selective replay retirement, durable per-ID settlement, stale-read fencing, lifecycle recovery, native scalar encoding, and prior wire compatibility. They run in the package test campaign; generated owners expose `OFFLINE_ORACLE_{SEED,PATH,RUNS}`, and the scheduler and interruption-settlement owners use the shared `TANSTACK_DB_ORACLE_{SEED,PATH,PROPERTY}` replay interface. | | [#1842](https://github.com/TanStack/db/pull/1842) | No shipped-law case | The PR changed only focused observer tests and introduced no production behavior. `packages/db/tests/live-query-observer.test.ts` remains the correct evidence; no synthetic oracle or campaign claim is added. | [PR #1816](https://github.com/TanStack/db/pull/1816) preserves existing witnesses, diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts index 647469bb3f..3406940508 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-core-adapter-oracle.test.ts @@ -12,6 +12,7 @@ import { IR, createCollection } from '@tanstack/db' import { oraclePropertyOptions, oracleReplayTest, + oracleRuns, } from '../../db/tests/oracle-config.js' import { SQLiteCorePersistenceAdapter, @@ -4420,7 +4421,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { `preserves exact rows across fixed-seed binding histories`, async () => { await campaign(`fixed seed`, { - numRuns: BINDING_RUNS, + numRuns: oracleRuns(BINDING_RUNS), seed: BINDING_FIXED_SEED, }) }, diff --git a/packages/db/tests/comparison-oracle.property.test.ts b/packages/db/tests/comparison-oracle.property.test.ts index 449563f40a..b60cbf20a6 100644 --- a/packages/db/tests/comparison-oracle.property.test.ts +++ b/packages/db/tests/comparison-oracle.property.test.ts @@ -74,9 +74,9 @@ const nullsLastOpts: CompareOptions = { // These properties judge immediate comparator and normalization results; there // is no mutable history or cleanup. Each function return is its observation cut. // The fixed campaign preserves one sample set, while the random campaign explores -// new values. Set all three variables to replay one property directly, for -// example COMPARISON_ORACLE_PROPERTY=1 COMPARISON_ORACLE_SEED=123 -// COMPARISON_ORACLE_PATH='0:0' pnpm --filter @tanstack/db exec vitest run +// new values. A path replay runs one property directly, for example +// TANSTACK_DB_ORACLE_PROPERTY=comparison.1 TANSTACK_DB_ORACLE_SEED=123 +// TANSTACK_DB_ORACLE_PATH='0:0' pnpm --filter @tanstack/db exec vitest run // --configLoader runner tests/comparison-oracle.property.test.ts. const fixedSeed = 20260911 const campaignRuns = 100 diff --git a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts index 12e09ac5d9..d6903b9340 100644 --- a/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-history-oracle.test.ts @@ -22,6 +22,7 @@ import { oraclePropertyOptions, oracleReplayRequested, oracleReplaySkips, + oracleRuns, } from '../oracle-config.js' import { runCampaign } from './campaign' import { runTransportHistory } from './cross-tab-driver' @@ -64,7 +65,7 @@ const action = ( ): TransportStep => ({ kind, tab, key, value: 2, optional: true }) const stores = stress ? [0, 0, 1, 0, 1] : [0, 0, 1] -async function campaign(seed?: number, path?: string) { +async function campaign(numRuns: number, seed?: number, path?: string) { await runCampaign( 'transport', history, @@ -77,7 +78,7 @@ async function campaign(seed?: number, path?: string) { 'registered campaign calibration', ) }, - { runs, seed, path }, + { runs: numRuns, seed, path }, ) } @@ -87,14 +88,17 @@ const crossTabProperty = `indexeddb.cross-tab-history` it.skipIf(oracleReplayRequested())( 'refines independent per-Collection snapshots with fixed histories', - () => campaign(11792026), + () => campaign(oracleRuns(runs), 11792026), 120_000, ) it.skipIf(oracleReplaySkips(crossTabProperty))( 'refines independent per-Collection snapshots with fresh histories', () => { - const { seed, path } = oraclePropertyOptions(runs, crossTabProperty) - return campaign(seed, path) + const { numRuns, seed, path } = oraclePropertyOptions( + runs, + crossTabProperty, + ) + return campaign(numRuns, seed, path) }, 120_000, ) diff --git a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts index c1db194015..466fc51910 100644 --- a/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts +++ b/packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts @@ -415,9 +415,8 @@ describe(`LocalStorage value fidelity oracle`, () => { ] as const for (const entry of entries) { for (const handler of [true, false]) { - for (const payload of pinned.filter((candidate) => - isLegal({ entry, handler, payload: candidate }), - )) { + for (const payload of pinned) { + if (!isLegal({ entry, handler, payload })) continue it(`${entry} ${handler ? `with` : `without`} a handler of ${payload.label} ${payload.restored === LOST ? `rejects` : `persists its JSON form`}`, () => runHistory({ entry, handler, payload })) } diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index e370f1f4c7..3ae3468866 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -42,6 +42,7 @@ const staticOracleProperties = [ `electric.valid-batch-partition`, `ivm.index-refinement`, `offline.key-scheduler-fifo-ledger`, + `offline.interruption-settlement`, `op-sqlite.columnar-sql-aliases`, `op-sqlite.result-carrier-equivalence`, `op-sqlite.write-marker-aliases`, @@ -375,9 +376,61 @@ export type OracleReplayConfig = { replayProperty: string | undefined } +/** + * Replay variables that oracles read before they moved to the shared + * TANSTACK_DB_ORACLE_* variables. Nothing reads them now, so a command that + * still sets one would run the full campaigns and appear to pass. Reading the + * config rejects them instead. + */ +const retiredReplayVariables = [ + `UTILS_ORACLE_PROPERTY`, + `UTILS_ORACLE_SEED`, + `UTILS_ORACLE_PATH`, + `COMPARISON_ORACLE_PROPERTY`, + `COMPARISON_ORACLE_SEED`, + `COMPARISON_ORACLE_PATH`, + `PG_SERIALIZER_ORACLE_PROPERTY`, + `PG_SERIALIZER_ORACLE_SEED`, + `PG_SERIALIZER_ORACLE_PATH`, + `PG_SERIALIZER_ORACLE_RUNS`, + `TANSTACK_DB_ELECTRIC_ORACLE_PROPERTY`, + `TANSTACK_DB_ELECTRIC_HISTORY_PROPERTY`, + `TANSTACK_DB_ELECTRIC_HISTORY_SEED`, + `TANSTACK_DB_ELECTRIC_HISTORY_PATH`, + `TANSTACK_DB_ELECTRIC_DURABILITY_SEED`, + `TANSTACK_DB_ELECTRIC_DURABILITY_PATH`, + `TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_SEED`, + `TANSTACK_DB_ELECTRIC_PERSISTENCE_POLICY_PATH`, + `TANSTACK_DB_IVM_INDEX_SEED`, + `TANSTACK_DB_IVM_INDEX_PATH`, + `TANSTACK_DB_SQLITE_BINDING_SEED`, + `TANSTACK_DB_SQLITE_BINDING_PATH`, + `TANSTACK_DB_SQLITE_ORACLE_SEED`, + `TANSTACK_DB_SQLITE_ORACLE_PATH`, + `TANSTACK_DB_SQLITE_ORACLE_RUNS`, + `TANSTACK_DB_WS5A_PROPERTY`, + `TANSTACK_DB_WS5A_SEED`, + `TANSTACK_DB_WS5A_PATH`, + `TANSTACK_INDEXEDDB_ORACLE_SEED`, + `TANSTACK_INDEXEDDB_ORACLE_PATH`, + `TANSTACK_DB_OFFLINE_ORACLE_SEED`, + `TANSTACK_DB_OFFLINE_ORACLE_PATH`, + `TANSTACK_DB_OFFLINE_ORACLE_RUNS`, +] as const + export function readOracleRunConfig( environment: OracleEnvironment = process.env, ): OracleReplayConfig & { multiplier: number } { + const retired = retiredReplayVariables.filter( + (variable) => environment[variable] !== undefined, + ) + if (retired.length > 0) { + throw new Error( + `Retired oracle replay variables are set: ${retired.join(`, `)}. ` + + `Use TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, and ` + + `TANSTACK_DB_ORACLE_PROPERTY.`, + ) + } const multiplierValue = environment.TANSTACK_DB_ORACLE_RUNS_MULTIPLIER ?? `1` const multiplier = Number(multiplierValue) if ( @@ -533,6 +586,26 @@ export function oracleReplayTest( return standsAside ? (registration.skip as TRegistration) : registration } +/** + * The registration for a calibration witness. During a path replay of + * `property`, TANSTACK_DB_ORACLE_CALIBRATION selects a hostile mutant that the + * main property runs against, and the witness for that `fault` checks the + * failure. The witness runs in ordinary runs and in that calibration replay. + * It stands aside during every other path replay, where it has nothing to + * check. + */ +export function oracleCalibrationTest( + registration: TRegistration, + property: string, + fault: string, +): TRegistration { + const standsAside = + oracleReplaySkips(property) || + (oracleReplayRequested() && + process.env.TANSTACK_DB_ORACLE_CALIBRATION !== fault) + return standsAside ? (registration.skip as TRegistration) : registration +} + /** Keeps ordinary CI bounded while allowing long randomized oracle campaigns. */ export function oracleRuns(baseRuns: number): number { return baseRuns * multiplier diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 3139a9ad76..294689f7d3 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -213,6 +213,54 @@ const ownerGroups: ReadonlyArray = [ `persistence`, `retained-demand`, ], + // Owners of the replays that moved to the shared variables. The SQLite + // core-adapter contract's binding-capacity and baseline-lineage replays run + // through a host file that does not name them, so they have no named owner. + [ + `browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts`, + `opfs-worker`, + `diagnostics`, + ], + [ + `db/tests/indexed-db/cross-tab-history-oracle.test.ts`, + `indexeddb`, + `cross-tab-history`, + ], + [ + `electric-db-collection/tests/electric-oracle.property.test.ts`, + `electric`, + `adapter-failure-policy design-convergence durable-prefix initial-tagged-move-outs invalid-resume-partition metadata-merge-complete-state process-grammar publication-before-persistence queued-presence-order resume-metadata-merge retry-decision subset-request-row-validity synthetic-batch-partition valid-batch-partition`, + ], + [ + `electric-db-collection/tests/pg-serializer-oracle.property.test.ts`, + `pg-serializer`, + `array-determinism bigint-round-trip bigint-text boolean-array-contents boolean-round-trip boolean-tokens date-iso date-round-trip double-round-trip integer-array-contents integer-array-format integer-round-trip integer-text mixed-array-contents nullish-array-contents nullish-empty scalar-determinism string-array-contents string-escaping string-idempotent string-passthrough`, + ], + [ + `db-ivm/tests/index-refinement-oracle.property.test.ts`, + `ivm`, + `index-refinement`, + ], + [ + `offline-transactions/tests/KeyScheduler-oracle.property.test.ts`, + `offline`, + `key-scheduler-fifo-ledger`, + ], + [ + `offline-transactions/tests/interruption-settlement-oracle.property.test.ts`, + `offline`, + `interruption-settlement`, + ], + [ + `react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts`, + `op-sqlite`, + `columnar-sql-aliases result-carrier-equivalence write-marker-aliases`, + ], + [ + `node-db-sqlite-persistence/tests/expression-index-oracle.test.ts`, + `sqlite-persistence`, + `expression-index bigint-roundtrip bigint-rejection`, + ], ] const owners = new Map() @@ -249,6 +297,12 @@ for (const property of registeredOracleProperties) { `packages/db/tests/query/includes-oracle.property.test.ts`, ) } + if (property.startsWith(`utils.`)) { + owners.set(property, `packages/db/tests/utils-oracle.property.test.ts`) + } + if (property.startsWith(`comparison.`)) { + owners.set(property, `packages/db/tests/comparison-oracle.property.test.ts`) + } if (property.startsWith(`pagination.matrix.`)) { owners.set( property, diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index 6417c15f6f..c68dab0b81 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -433,6 +433,10 @@ describe(`named oracle replay manifest`, () => { expect(source).toContain( family?.[1] ?? `includes.matrix.flat-materialization`, ) + } else if (/^(utils|comparison)\.\d+$/.test(entry.property)) { + // These oracles number their properties in file order. + const [family] = entry.property.split(`.`) + expect(source).toContain(`\`${family}.\${id}\``) } else if (entry.property.startsWith(`pagination.matrix.`)) { const [, , familyCell] = entry.property.split(`.`) const [family, cell] = familyCell!.split(`-`) diff --git a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts index 53e38f0811..4a136ec386 100644 --- a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts +++ b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts @@ -16,6 +16,7 @@ import { toArray, } from '../../src/query/index.js' import { + oracleCalibrationTest, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, @@ -985,14 +986,6 @@ const windowedExamples: Array< describe(`includes cross-formulation oracle`, () => { const replay = readOracleRunConfig() - // A calibration witness injects a hostile mutant into its main property - // during a replay of that property, selected by - // TANSTACK_DB_ORACLE_CALIBRATION. It runs in ordinary runs and in replays of - // its property. A replay of the property without its fault replays the - // main property, so the witness has nothing to check then. - const calibrationIdle = (fault: string) => - oracleReplayRequested() && - process.env.TANSTACK_DB_ORACLE_CALIBRATION !== fault test(`bounded grammar axes distinguish routes, order, partitions, and windows`, () => { const parents = new Map( @@ -2173,10 +2166,13 @@ describe(`includes cross-formulation oracle`, () => { // tests/query/includes-cross-formulation-oracle.property.test.ts // -t 'agrees across nested includes' // Expected: one main-property failure at cross-formulation checkpoint 0. - oracleReplayTest(test, `includes-cross-formulation.equivalence`)( + oracleCalibrationTest( + test, + `includes-cross-formulation.equivalence`, + `missing-parent`, + )( `generated equivalence rejects a missing production parent and replays its checkpoint`, async () => { - if (calibrationIdle(`missing-parent`)) return // The hostile observation is injected after the real nested query is // captured. The same generated driver and refinement comparison execute // during shrinking and direct seed/path replay. @@ -2225,10 +2221,13 @@ describe(`includes cross-formulation oracle`, () => { // tests/query/includes-cross-formulation-oracle.property.test.ts // -t 'matches recomputation for ordered offset and limit child windows for a random' // Expected: one main-property failure at ordered-window checkpoint 0. - oracleReplayTest(test, `includes-cross-formulation.ordered-window`)( + oracleCalibrationTest( + test, + `includes-cross-formulation.ordered-window`, + `extra-child`, + )( `generated window rejects an extra production child and replays its checkpoint`, async () => { - if (calibrationIdle(`extra-child`)) return const property = fc.asyncProperty( windowedScenarioArbitrary, async (sample) => { diff --git a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts index 3e7d978e19..93cf6e9750 100644 --- a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts +++ b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts @@ -16,7 +16,6 @@ import { flushPromises, withExpectedRejection } from '../utils.js' import { runTrace } from '../trace-runner.js' import { oraclePropertyOptions, - oracleReplayRequested, oracleReplayTest, } from '../oracle-config.js' import { createControlledCollection as createOracleControlledCollection } from './includes-oracle-helpers.js' @@ -651,21 +650,16 @@ function optimisticProperty( run: (routes: RouteValues) => Promise, ): void { const options = oraclePropertyOptions(12, property) - // Both normal campaigns use the same arbitrary, driver, checker, and budget. - // A path replay registers only its requested property, as its one lane. - if (oracleReplayRequested()) { - oracleReplayTest(fcTest, property).prop([routeValuesArbitrary], options)( - name, - run, - ) - return - } - - fcTest.prop([routeValuesArbitrary], { ...options, seed: 1_658_013 })( - `${name} (fixed seed)`, + // Both campaigns use the same arbitrary, driver, checker, and budget. A path + // replay of this property runs only the random campaign, from its path. + oracleReplayTest(fcTest).prop([routeValuesArbitrary], { + ...options, + seed: 1_658_013, + })(`${name} (fixed seed)`, run) + oracleReplayTest(fcTest, property).prop([routeValuesArbitrary], options)( + `${name} (random seed)`, run, ) - fcTest.prop([routeValuesArbitrary], options)(`${name} (random seed)`, run) } describe(`optimistic relationship-transition oracle`, () => { diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index c47cfa5d91..5ea782c83f 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -4982,29 +4982,14 @@ describe(`includes recompute oracle`, () => { `reparent`, `rekey`, ] - // A transition pair with no history placement at this depth has no - // history to generate, so the matrix registers no cell for it. - const transitionPairs = transitions - .flatMap((firstTransition, firstIndex) => - transitions.map((secondTransition, secondIndex) => ({ - firstIndex, - firstTransition, - secondIndex, - secondTransition, - })), - ) - .filter( - ({ firstTransition, secondTransition }) => + for (const [firstIndex, firstTransition] of transitions.entries()) { + for (const [secondIndex, secondTransition] of transitions.entries()) { + if ( transitionHistoryPlacements(depth, firstTransition, secondTransition) - .length > 0, - ) - for (const { - firstIndex, - firstTransition, - secondIndex, - secondTransition, - } of transitionPairs) { - { + .length === 0 + ) { + continue + } for (const sourceBranch of [0, 1] as const) { const property = `includes.matrix.transition-${depth}-${firstTransition}-${secondTransition}-${sourceBranch}` const arbitrary = transitionHistoryScenariosArbitrary( diff --git a/packages/db/tests/utils-oracle.property.test.ts b/packages/db/tests/utils-oracle.property.test.ts index 3857d61052..050b9bd6cc 100644 --- a/packages/db/tests/utils-oracle.property.test.ts +++ b/packages/db/tests/utils-oracle.property.test.ts @@ -29,9 +29,10 @@ import { * reference relation does not define. */ // Every generated law below uses the same property in a fixed and a fresh -// campaign. Set UTILS_ORACLE_PROPERTY to the numbered test label and provide -// UTILS_ORACLE_SEED and UTILS_ORACLE_PATH to replay its fast-check shrink -// directly, for example with `vitest run tests/utils-oracle.property.test.ts`. +// campaign. Set TANSTACK_DB_ORACLE_PROPERTY to `utils.` from the test +// label, with TANSTACK_DB_ORACLE_SEED and TANSTACK_DB_ORACLE_PATH, to replay +// its fast-check shrink directly, for example with +// `vitest run tests/utils-oracle.property.test.ts`. const fixedSeed = 20260911 const campaignRuns = 100 // Each property registers as `utils.`, in file order. A path replay @@ -1177,7 +1178,8 @@ describe(`deepEquals property-based tests`, () => { }) // Keep newly added generated laws after the original registration order: - // UTILS_ORACLE_PROPERTY uses ordinal selectors for saved seed/path replays. + // `utils.` names properties in registration order, so saved + // seed/path replays keep their numbers. describe(`additional non-ring value boundaries`, () => { utilsProperty([arbitraryRegExp])( `RegExp source and flags determine equality`, 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 e7668be696..4ddecf84e0 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 @@ -15,7 +15,6 @@ import { electricCollectionOptions } from '../src/electric' import { StreamAbortedError } from '../src/errors' import { oraclePropertyOptions, - oracleReplayRequested, oracleReplayTest, oracleRuns, } from '../../db/tests/oracle-config' @@ -608,32 +607,25 @@ fixedCase.each([ }, ) -// The two campaigns use the same grammar, checker, and budget. An explicit -// replay registers only its selected property. Reproduce a saved failure with: +// The two campaigns use the same grammar, checker, and budget. A path replay +// of the property runs only the random campaign. Reproduce a saved failure with: // TANSTACK_DB_ORACLE_PROPERTY=electric.persisted-tag-history // TANSTACK_DB_ORACLE_SEED= TANSTACK_DB_ORACLE_PATH= // pnpm exec vitest run tests/electric-descriptor-isolation-oracle.test.ts -if (oracleReplayRequested()) { - oracleReplayTest(fcTest, `electric.persisted-tag-history`).prop( - [tagHistory], - oraclePropertyOptions(10, `electric.persisted-tag-history`), - )( - `persisted tag histories preserve membership across warm and cold restart (replay)`, - (history) => runTagHistory(history), - ) -} else { - fcTest.prop([tagHistory], { seed: 42712, numRuns: oracleRuns(10) })( - `persisted tag histories preserve membership across warm and cold restart (fixed)`, - (history) => runTagHistory(history), - ) - fcTest.prop( - [tagHistory], - oraclePropertyOptions(10, `electric.persisted-tag-history`), - )( - `persisted tag histories preserve membership across warm and cold restart (random)`, - (history) => runTagHistory(history), - ) -} +oracleReplayTest(fcTest).prop([tagHistory], { + seed: 42712, + numRuns: oracleRuns(10), +})( + `persisted tag histories preserve membership across warm and cold restart (fixed)`, + (history) => runTagHistory(history), +) +oracleReplayTest(fcTest, `electric.persisted-tag-history`).prop( + [tagHistory], + oraclePropertyOptions(10, `electric.persisted-tag-history`), +)( + `persisted tag histories preserve membership across warm and cold restart (random)`, + (history) => runTagHistory(history), +) beforeEach(() => { streams.length = 0 @@ -1749,27 +1741,20 @@ async function runOwnerHistory(history: { } } -if (oracleReplayRequested()) { - oracleReplayTest(fcTest, `electric.bound-descriptor-history`).prop( - [ownerHistory], - oraclePropertyOptions(20, `electric.bound-descriptor-history`), - )( - `config derivation preserves independent owner histories (replay)`, - runOwnerHistory, - ) -} else { - fcTest.prop([ownerHistory], { seed: 42711, numRuns: oracleRuns(12) })( - `config derivation preserves independent owner histories (fixed)`, - runOwnerHistory, - ) - fcTest.prop( - [ownerHistory], - oraclePropertyOptions(20, `electric.bound-descriptor-history`), - )( - `config derivation preserves independent owner histories (random)`, - runOwnerHistory, - ) -} +oracleReplayTest(fcTest).prop([ownerHistory], { + seed: 42711, + numRuns: oracleRuns(12), +})( + `config derivation preserves independent owner histories (fixed)`, + runOwnerHistory, +) +oracleReplayTest(fcTest, `electric.bound-descriptor-history`).prop( + [ownerHistory], + oraclePropertyOptions(20, `electric.bound-descriptor-history`), +)( + `config derivation preserves independent owner histories (random)`, + runOwnerHistory, +) // The Electric descriptor may be reused across independent persisted wrappers. // The wrappers may address the same or different durable Collection IDs; that 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 b76cad3692..7eb9e01591 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -17,7 +17,6 @@ import { queryCollectionOptions } from '../../query-db-collection/src/query' import { electricCollectionOptions } from '../src/electric' import { oraclePropertyOptions, - oracleReplayRequested, oracleReplayTest, oracleRuns, } from '../../db/tests/oracle-config' @@ -65,24 +64,17 @@ function electricHistoryProperty( throw new Error(`duplicate Electric history property: ${property}`) } registeredHistoryProperties.add(property) - if (!oracleReplayRequested()) { - fcTest.prop([arbitrary], { - numRuns: oracleRuns(baseRuns), - seed: fixedSeed, - examples, - })(`${name} (fixed)`, check, timeout) - fcTest.prop([arbitrary], { - ...oraclePropertyOptions(baseRuns, property), - examples, - })(`${name} (random)`, check, timeout) - return - } - // A path replay runs only the selected history. The registration stands - // aside for every other property. + // A path replay of this property runs only the random campaign, from its + // path. The fixed campaign stands aside during every path replay. + oracleReplayTest(fcTest).prop([arbitrary], { + numRuns: oracleRuns(baseRuns), + seed: fixedSeed, + examples, + })(`${name} (fixed)`, check, timeout) oracleReplayTest(fcTest, property).prop([arbitrary], { ...oraclePropertyOptions(baseRuns, property), examples, - })(`${name} (replay)`, check, timeout) + })(`${name} (random)`, check, timeout) } /** @@ -646,7 +638,10 @@ function queuedPresencePropertyOptions(mode: `fixed` | `random`): { path?: string } { if (mode === `fixed`) { - return { numRuns: queuedPresenceRuns, seed: queuedPresenceFixedSeed } + return { + numRuns: oracleRuns(queuedPresenceRuns), + seed: queuedPresenceFixedSeed, + } } return oraclePropertyOptions(queuedPresenceRuns, queuedPresenceProperty) } @@ -3252,14 +3247,6 @@ describe(`Electric adapter laws`, () => { runQueuedPresenceCampaign, ) - oracleReplayTest(fcTest).prop( - queuedPresenceArbitraries, - queuedPresencePropertyOptions(`random`), - )( - `generated queued Electric histories preserve staged presence and reset fences (random)`, - runQueuedPresenceCampaign, - ) - const publicationPolicyArbitraries: [ fc.Arbitrary, fc.Arbitrary, @@ -7705,46 +7692,36 @@ describe(`Electric adapter laws`, () => { `preserves persisted Electric interleavings (fixed)`, runPersistenceInterleavingCampaign, ) - - oracleReplayTest(fcTest).prop( - [persistenceInterleavingCampaignArbitrary], - oraclePropertyOptions(4, persistenceInterleavingProperty), - )( - `preserves persisted Electric interleavings (random)`, - runPersistenceInterleavingCampaign, - ) }) -// The replay suites register only during a path replay. Each stands aside -// unless the replay selects its own property. -if (oracleReplayRequested()) { - oracleReplayTest(describe, persistenceInterleavingProperty)( - `persisted Electric interleaving replay`, - () => { - beforeEach(resetElectricOracleMocks) - - fcTest.prop( - [persistenceInterleavingCampaignArbitrary], - oraclePropertyOptions(4, persistenceInterleavingProperty), - )( - `preserves persisted Electric interleavings (replay)`, - runPersistenceInterleavingCampaign, - ) - }, - ) +// Each campaign's random lane has its own suite. An ordinary run runs it +// beside the adapter laws. A path replay of its property runs only it. +oracleReplayTest(describe, persistenceInterleavingProperty)( + `persisted Electric interleaving campaign`, + () => { + beforeEach(resetElectricOracleMocks) + + fcTest.prop( + [persistenceInterleavingCampaignArbitrary], + oraclePropertyOptions(4, persistenceInterleavingProperty), + )( + `preserves persisted Electric interleavings (random)`, + runPersistenceInterleavingCampaign, + ) + }, +) - oracleReplayTest(describe, queuedPresenceProperty)( - `Electric queued-presence replay`, - () => { - beforeEach(resetElectricOracleMocks) - - fcTest.prop( - queuedPresenceArbitraries, - oraclePropertyOptions(queuedPresenceRuns, queuedPresenceProperty), - )( - `generated queued Electric histories preserve staged presence and reset fences (replay)`, - runQueuedPresenceCampaign, - ) - }, - ) -} +oracleReplayTest(describe, queuedPresenceProperty)( + `Electric queued-presence campaign`, + () => { + beforeEach(resetElectricOracleMocks) + + fcTest.prop( + queuedPresenceArbitraries, + queuedPresencePropertyOptions(`random`), + )( + `generated queued Electric histories preserve staged presence and reset fences (random)`, + runQueuedPresenceCampaign, + ) + }, +) 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 fb7278da26..c60c541519 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 @@ -16,7 +16,6 @@ import { import { electricCollectionOptions } from '../src/electric' import { oraclePropertyOptions, - oracleReplayRequested, oracleReplayTest, } from '../../db/tests/oracle-config' import { @@ -123,12 +122,10 @@ function sdkCampaigns( throw violation.report(error) } } - if (oracleReplayRequested()) { - oracleReplayTest(it, propertyId)(`${name} (replay)`, () => runCampaign()) - return - } - it(`${name} (fixed)`, () => runCampaign(fixedSeed)) - it(`${name} (random)`, () => runCampaign()) + // A path replay of the property runs only the random campaign, from its + // path. The fixed campaign stands aside during every path replay. + oracleReplayTest(it)(`${name} (fixed)`, () => runCampaign(fixedSeed)) + oracleReplayTest(it, propertyId)(`${name} (random)`, () => runCampaign()) } function violationCheckpoint(error: unknown): string { diff --git a/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts b/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts index 6d13fd6ed2..c75e38fb9e 100644 --- a/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/pg-serializer-oracle.property.test.ts @@ -3,7 +3,6 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { serialize } from '../src/pg-serializer' import { oraclePropertyOptions, - oracleReplayRequested, oracleReplayTest, oracleRuns, } from '../../db/tests/oracle-config' @@ -24,21 +23,16 @@ function serializerProp(property: string, [arbitrary]: [fc.Arbitrary]) { throw new Error(`duplicate PostgreSQL serializer property: ${name}`) } registeredProperties.add(name) - if (oracleReplayRequested()) { - oracleReplayTest(fcTest, property).prop( - [arbitrary], - oraclePropertyOptions(100, property), - )(`${name} (replay)`, check) - return - } - fcTest.prop([arbitrary], { numRuns: oracleRuns(100), seed: 20260930 })( - `${name} (fixed)`, - check, - ) - fcTest.prop([arbitrary], oraclePropertyOptions(100))( - `${name} (random)`, - check, - ) + // A path replay of the property runs only the random campaign, from its + // path. The fixed campaign stands aside during every path replay. + oracleReplayTest(fcTest).prop([arbitrary], { + numRuns: oracleRuns(100), + seed: 20260930, + })(`${name} (fixed)`, check) + oracleReplayTest(fcTest, property).prop( + [arbitrary], + oraclePropertyOptions(100, property), + )(`${name} (random)`, check) } } diff --git a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts index b4ffe587e0..6322911209 100644 --- a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts @@ -1,13 +1,16 @@ import fc from 'fast-check' import { expect, it, vi } from 'vitest' import { NonRetriableError } from '../src/types' +import { + oraclePropertyOptions, + oracleReplayTest, +} from '../../db/tests/oracle-config' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import { atOracleCheckpoint, captureUnhandledRejections, cleanupOfflineOracle, } from './oracle-lifecycle' -import { readOfflineOracleConfig } from './oracle-config' import type { OnlineDetector } from '../src/types' import type { TestItem } from './harness' @@ -79,10 +82,9 @@ type History = { turn: number } -const config = readOfflineOracleConfig({ - prefix: `TANSTACK_DB_OFFLINE_ORACLE`, - defaultRuns: 60, -}) +// A path replay of this property runs only the generated campaign, from its +// recorded seed and path. +const interruptionProperty = `offline.interruption-settlement` const history: fc.Arbitrary = fc.record({ outcomes: fc.array(fc.constantFrom(`fulfil`, `reject`), { @@ -410,24 +412,24 @@ it.each(fixedLandings)( 60_000, ) -it(`keeps settlement and ownership for an interruption at any microtask turn`, async () => { - // A permanent provider failure logs once by design; this law ignores logs. - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) - try { - await fc.assert( - fc.asyncProperty(history, async (generated) => { - // Settlement and ownership must hold for every generated history, - // regardless of which landing it reaches. Coverage is verified by the - // fixed tests above; this campaign proves the core law. - await checkInterruption(generated) - }), - { - numRuns: config.runs, - ...(config.seed === undefined ? {} : { seed: config.seed }), - ...(config.path === undefined ? {} : { path: config.path }), - }, - ) - } finally { - warn.mockRestore() - } -}, 120_000) +oracleReplayTest(it, interruptionProperty)( + `keeps settlement and ownership for an interruption at any microtask turn`, + async () => { + // A permanent provider failure logs once by design; this law ignores logs. + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + try { + await fc.assert( + fc.asyncProperty(history, async (generated) => { + // Settlement and ownership must hold for every generated history, + // regardless of which landing it reaches. Coverage is verified by the + // fixed tests above; this campaign proves the core law. + await checkInterruption(generated) + }), + oraclePropertyOptions(60, interruptionProperty), + ) + } finally { + warn.mockRestore() + } + }, + 120_000, +) diff --git a/packages/offline-transactions/tests/oracle-config.ts b/packages/offline-transactions/tests/oracle-config.ts index 8384b6da64..9f1cef6db1 100644 --- a/packages/offline-transactions/tests/oracle-config.ts +++ b/packages/offline-transactions/tests/oracle-config.ts @@ -1,7 +1,7 @@ type OracleEnvironment = Record type OfflineOracleConfigOptions = { - prefix: `OFFLINE_ORACLE` | `TANSTACK_DB_OFFLINE_ORACLE` + prefix: `OFFLINE_ORACLE` defaultRuns: number defaultSeed?: number environment?: OracleEnvironment diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js index 1850432700..c71fe48fab 100644 --- a/scripts/eslint-plugin-oracle/no-disabled.test.js +++ b/scripts/eslint-plugin-oracle/no-disabled.test.js @@ -45,7 +45,7 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { ...[ `it.skipIf(oracleReplaySkips('cursor.exact-width'))('a', () => check())`, `if (!oracleReplayRequested()) { it('a', () => check()) }`, - `if (oracleReplayRequested()) { it('replays the path', () => check()) }`, + `if (oracleReplayRequested()) { replayOnly() } else { it('a', () => check()) }`, `const propertyTest = oracleReplayTest(fcTest, 'cursor.exact-width')\npropertyTest.prop([a])('a', check)`, `describe('s', () => { if (oracleReplaySkips('cursor.exact-width')) return\n it('a', () => check()) })`, ].map((code) => ({ @@ -54,6 +54,8 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { })), // Playwright's test API calls that register nothing. `test('a', async () => { if (rejected) { expect(test.info().project.name).not.toBe('webkit') } })`, + // A guard that skips a loop item filters data, as \`.filter\` would. + `for (const c of cases) { if (!c.legal) continue\n it(c.name, () => check(c)) }`, // Registrations in a loop or over a table run in every ordinary run. `for (const campaign of campaigns) { it(campaign.name, () => check(campaign)) }`, `const fixedRuns = { seed: 1846, numRuns: 40 }`, @@ -274,6 +276,41 @@ ruleTester.run(`oracle/no-disabled`, noDisabled, { code: `fcTest.prop([history], { numRuns: 200 })('checks the history', check)`, errors: [{ messageId: `literalRuns` }], }, + { + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nif (process.env.CI) { oracleReplayTest(it)('a', () => check()) }`, + languageOptions: { sourceType: `module` }, + errors: [{ messageId: `guarded` }], + }, + { + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\noracleReplayTest(it).only('a', () => check())`, + languageOptions: { sourceType: `module` }, + errors: [{ messageId: `only` }], + }, + { + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\noracleReplayTest(it).skip('a', () => check())`, + languageOptions: { sourceType: `module` }, + errors: [{ messageId: `skip` }], + }, + { + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\noracleReplayTest(it).skipIf(process.env.CI)('a', () => check())`, + languageOptions: { sourceType: `module` }, + errors: [{ messageId: `conditional` }], + }, + { + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nif (oracleReplayRequested()) { it('law', () => check()) }`, + languageOptions: { sourceType: `module` }, + errors: [{ messageId: `guarded` }], + }, + { + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\ndescribe('s', () => { if (!oracleReplayRequested()) return\n it('a', () => check()) })`, + languageOptions: { sourceType: `module` }, + errors: [{ messageId: `guarded` }], + }, + { + code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\noracleReplayRequested() && it('law', () => check())`, + languageOptions: { sourceType: `module` }, + errors: [{ messageId: `guarded` }], + }, { // A comment does not grant an exception. code: ` diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js index b35b111dec..9fa5438308 100644 --- a/scripts/eslint-plugin-oracle/rules/no-disabled.js +++ b/scripts/eslint-plugin-oracle/rules/no-disabled.js @@ -16,9 +16,13 @@ * - A test modifier passed around as a value, as in * `const run = replaying ? it.skip : it`. The rule cannot see what such an * alias switches off. `oracleReplayTest(test, property)` selects a replay. - * - A test registered under a condition, or after an early `return`, - * unless the condition is a shared replay helper or its negation. An early - * `throw` fails the run loudly, so it guards nothing. + * - A test registered under a condition, or after an early `return`, unless + * the condition is a shared replay helper and the test registers when the + * helper is false, as in `if (!oracleReplayRequested())`. Each helper is + * false in an ordinary run, so such a test runs there. An early `throw` + * fails the run loudly, and `continue` filters a loop's data, so neither + * guards a registration. A registration made through `oracleReplayTest` + * or `oracleCalibrationTest` is checked as a registration of its test. * - Playwright's runtime `test.skip()` or `test.fixme()` inside a test, * unless its condition is `browserName !== ''`: a control that * only one browser supports. @@ -35,11 +39,11 @@ * The rule has no inline exception. A known gap is pinned with `it.fails` on * a fixed seed, not skipped. * - * The rule cannot see a run count lowered to any value above 1, a seed read - * from configuration that is absent in ordinary runs, a test function renamed - * before its modifier is read (`const t = it; t.skip(...)`), or a call to a - * local helper that registers tests under a condition. Reviewers still check - * those. + * The rule cannot see a run count lowered to any value above 1, a run count + * or seed held in a variable, a test function renamed before its modifier is + * read (`const t = it; t.skip(...)`), a call to a local helper that registers + * tests under a condition, or a condition inside a loop's iterable, as in + * `cases.filter(() => !process.env.CI)`. Reviewers still check those. */ const testRoots = new Set([`describe`, `fcTest`, `it`, `suite`, `test`]) @@ -66,7 +70,10 @@ const registrationMembers = new Set([ ]) // oracle-config implements replay selection, so its options may carry a // replay path from configuration. -const replaySelectionModule = /(^|[/\\])oracle-config\.ts$/ +const replaySelectionModule = + /(^|[/\\])packages[/\\]db[/\\]tests[/\\]oracle-config\.ts$/ +// The shared helpers that return a test registration or its `skip` form. +const selectionHelpers = new Set([`oracleCalibrationTest`, `oracleReplayTest`]) const runHelpers = new Set([ `oraclePropertyOptions`, `oracleRandomParameters`, @@ -81,6 +88,15 @@ function calleeChain(callee) { if (node.type === `MemberExpression`) { if (node.property.type === `Identifier`) names.unshift(node.property.name) node = node.object + } else if ( + node.type === `CallExpression` && + node.callee.type === `Identifier` && + selectionHelpers.has(node.callee.name) && + node.arguments.length > 0 + ) { + // `oracleReplayTest(it)` returns `it` or `it.skip`, so a registration + // made through it is checked as a registration of `it`. + node = node.arguments[0] } else if (node.type === `CallExpression`) { node = node.callee } else { @@ -146,15 +162,6 @@ function isSharedHelperCall(node, names, sourceCode) { ) } -/** A shared replay condition, or its negation. */ -function isReplayCondition(node, sourceCode) { - const condition = - node.type === `UnaryExpression` && node.operator === `!` - ? node.argument - : node - return isSharedHelperCall(condition, replayConditions, sourceCode) -} - /** Exactly `.skipIf()`, not negated. */ function isReplaySelection({ name, args }, sourceCode) { return ( @@ -165,31 +172,28 @@ function isReplaySelection({ name, args }, sourceCode) { } /** - * Whether a statement always leaves its block without failing. A `throw` - * fails the run loudly, so it guards nothing. + * Whether a statement always returns from its function. A `throw` fails the + * run loudly, and `continue` or `break` skips one item of a loop's data, as + * `.filter` would, so neither guards a registration. */ -function exits(statement) { +function returns(statement) { if (statement === null || statement === undefined) return false - if ( - statement.type === `ReturnStatement` || - statement.type === `ContinueStatement` || - statement.type === `BreakStatement` - ) { - return true - } + if (statement.type === `ReturnStatement`) return true if (statement.type === `BlockStatement`) { - return exits(statement.body.at(-1)) + return returns(statement.body.at(-1)) } if (statement.type === `IfStatement`) { - return exits(statement.consequent) && exits(statement.alternate) + return returns(statement.consequent) && returns(statement.alternate) } return false } /** - * The conditions that decide whether `node` runs: every enclosing `if`, + * The conditions that decide whether `node` runs, each with the value that + * the condition must have for `node` to run: every enclosing `if`, * conditional, or logical branch, and every earlier `if` in an enclosing - * block that leaves the block. + * block that returns. `registersWhen` is undefined when no single value + * decides it, as for `??`. */ function guardingConditions(node) { const conditions = [] @@ -200,9 +204,20 @@ function guardingConditions(node) { parent.type === `ConditionalExpression`) && child !== parent.test ) { - conditions.push(parent.test) + conditions.push({ + test: parent.test, + registersWhen: child === parent.consequent, + }) } else if (parent.type === `LogicalExpression` && child === parent.right) { - conditions.push(parent.left) + conditions.push({ + test: parent.left, + registersWhen: + parent.operator === `&&` + ? true + : parent.operator === `||` + ? false + : undefined, + }) } else if ( parent.type === `BlockStatement` || parent.type === `Program` || @@ -211,8 +226,8 @@ function guardingConditions(node) { const body = parent.type === `SwitchCase` ? parent.consequent : parent.body for (const statement of body.slice(0, body.indexOf(child))) { - if (statement.type === `IfStatement` && exits(statement.consequent)) { - conditions.push(statement.test) + if (statement.type === `IfStatement` && returns(statement.consequent)) { + conditions.push({ test: statement.test, registersWhen: false }) } } } @@ -221,6 +236,21 @@ function guardingConditions(node) { return conditions } +/** + * Whether a guard keeps a registration in every ordinary run. Each shared + * replay helper is false in an ordinary run, so the registration must run + * when the helper is false: under `!helper()`, in the `else` branch of + * `helper()`, or after `if (helper()) return`. A registration that runs only + * when a helper is true would run only during a replay. + */ +function keepsOrdinaryRun({ test, registersWhen }, sourceCode) { + if (registersWhen === undefined) return false + const negated = test.type === `UnaryExpression` && test.operator === `!` + const helper = negated ? test.argument : test + if (!isSharedHelperCall(helper, replayConditions, sourceCode)) return false + return negated ? registersWhen : !registersWhen +} + /** A `path` that a witness recorded: a literal, or a failure's path. */ function isRecordedPath(node) { if (node.type === `TemplateLiteral`) return node.expressions.length === 0 @@ -414,7 +444,7 @@ export default { return } const unguarded = guardingConditions(node).every((condition) => - isReplayCondition(condition, sourceCode), + keepsOrdinaryRun(condition, sourceCode), ) if (!unguarded) { context.report({ node, messageId: `guarded` }) From e778b9bca458abff1b87088772981858887c0fd7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:48:22 +0000 Subject: [PATCH 8/8] ci: apply automated fixes --- .../tests/query/includes-optimistic-oracle.property.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts index 93cf6e9750..9190d819d3 100644 --- a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts +++ b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts @@ -14,10 +14,7 @@ import { import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { flushPromises, withExpectedRejection } from '../utils.js' import { runTrace } from '../trace-runner.js' -import { - oraclePropertyOptions, - oracleReplayTest, -} from '../oracle-config.js' +import { oraclePropertyOptions, oracleReplayTest } from '../oracle-config.js' import { createControlledCollection as createOracleControlledCollection } from './includes-oracle-helpers.js' import type { TraceDriver, TraceProjection } from '../trace-runner.js' import type { OracleSyncChange as SyncChange } from './includes-oracle-helpers.js'