diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index dfd5454348..8489095fba 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -22,14 +22,14 @@ Work in this order when a production change is part of the task: 2. Inventory the materially affected public receiving paths and their promised observation cuts. Include required real-host receiving witnesses in this pre-edit gate; a controlled core witness does not supply their evidence. -3. Extend the history grammar, driver, or observation until generated +3. Extend the history grammar, production driver, or observation until generated histories reach the state where the law matters. Declare that state with `createOracleReach` (ORC-015) for each required receiving path. 4. Run those comparisons on unchanged production. Record the defect's RED result and the neighboring guarantees' results. A setup failure or missing required witness blocks production edits; repair the test path first. 5. Change production, with the oracle running after each attempt. -6. Record the GREEN result and a hostile mutant that fails. +6. Record the GREEN result and a mutant that fails. Do not change production before step 4. Retrospective runs or a documented process gap do not satisfy this gate. If a model rule disagrees with @@ -38,7 +38,7 @@ change only so that it matches production output is model hacking. Read [the oracle guide](../../../docs/contributing/oracle-rubric.md) and [the glossary](../../../docs/contributing/glossary.md) in full, then read the -relevant owner and limits in +relevant oracle owner and limits in [the coverage map](../../../docs/contributing/oracle-coverage.md). After a handoff or compaction, reopen required sources whose relevant contents @@ -51,11 +51,11 @@ concepts. Delegated agents must do the reading relevant to their own scope. Follow the subsystem reading requirements in AGENTS.md. Keep unit tests and standalone concrete type assertions out of oracle files -(ORC-016). Fixed regressions are inputs to the same model, driver, and -comparison as the broader campaign, without their own expected answers. -Compile-time oracles generate legal TypeScript inputs and compare public -types with a reusable independent law; a matrix of fixed assertions does not -satisfy this requirement. Supplemental examples belong in ordinary test files. +(ORC-016). Fixed regressions are inputs to the same model, production driver, +and comparison as the broader campaign, without their own expected answers. +Compile-time oracles generate legal TypeScript inputs and compare public types +with a reusable independent law; a matrix of fixed assertions does not satisfy +this requirement. Supplemental examples belong in ordinary test files. Use the [instrument library](../../../docs/contributing/instruments/index.md) when there is a material uncertainty. Select cards within the authorized task; @@ -90,9 +90,9 @@ when requested by the task. A proposal with unresolved authority can be explored in a scratch model; do not silently make it a product expectation. At closeout, write the evidence block from AGENTS.md (law, reach, RED, GREEN, -mutant, limits). Connect the law to the evidence actually obtained: which histories -and paths ran, which wrong behavior was rejected, and what remains untested. -Use concise prose suited to the change; no particular report template is -required. When changing repository coverage, update the existing owner and -coverage map to reflect the changed scope. A scratch law-design experiment +mutant, limits). Connect the law to the evidence actually obtained: which +histories and paths ran, which wrong behavior was rejected, and what remains +untested. Use concise prose suited to the change; no particular report template +is required. When changing repository coverage, update the existing oracle owner +and coverage map to reflect the changed scope. A scratch law-design experiment retains its proposals and limits without claiming new repository coverage. diff --git a/.agents/skills/oracle-review/SKILL.md b/.agents/skills/oracle-review/SKILL.md index da83d6f213..b8201b4178 100644 --- a/.agents/skills/oracle-review/SKILL.md +++ b/.agents/skills/oracle-review/SKILL.md @@ -11,7 +11,7 @@ test can coexist with correct production. Read [the oracle guide](../../../docs/contributing/oracle-rubric.md) and [the glossary](../../../docs/contributing/glossary.md) in full, then read the -relevant owner and limits in +relevant oracle owner and limits in [the coverage map](../../../docs/contributing/oracle-coverage.md). After a handoff or compaction, reopen required sources whose relevant contents @@ -41,8 +41,9 @@ from the [instrument library](../../../docs/contributing/instruments/index.md): - **Law restructuring** tests whether merging, splitting, replacing, or removing laws gives a better account while preserving necessary obligations. - **Mutant gap hunt** tests whether a plausible wrong implementation can pass - the owner and traces survivors to a missing distinction, a contract-permitted - difference, a scoped equivalence argument, or a baseline product bug. + the oracle owner and traces survivors to a missing distinction, a + contract-permitted difference, a scoped equivalence argument, or a baseline + product bug. Choose attacks for the actual uncertainty; do not run every card or require a finding at every level. Use a fresh reviewer for an independent challenge when @@ -76,10 +77,48 @@ Report each one as a finding: - A claim about a state that no declared reach shows generated histories enter. - A skipped, narrowed, or relaxed comparison without a coverage-map entry and a maintainer decision. -- A pinned example where the law needs a generator dimension. +- A fixed-history-only coverage claim where the law needs a generator dimension. - A counterexample labeled as older than the change and then left without a fix, a coverage-map record, or a maintainer decision. -- A closure claim without a hostile mutant that fails the oracle. +- A closure claim without a mutant that fails the oracle. + +Lint, reach checks, coverage, and calibrations cannot see the weaknesses +below. Treat them as your main job, and hunt for each one in the oracle under +review, not only in the diff: + +- **Run but not checked.** A history executes the code, but the comparison + never observes its effect. Coverage and declared reach both pass. Find a + line the oracle claims, and name the mutant on it that would survive. +- **A model that copies production.** The model mirrors production's branches, + classifications, or helpers, so both agree on the same wrong answer. Compare + the model's structure with production, not only its results. +- **A grammar narrower than the law.** The law's prose claims a value domain, + action, or case that the grammar never generates. Examples are a type the + generator never produces, an exclusion stated in prose but missing from the + declared limits, and a size or length setting that makes part of the + declared range unreachable. Check each claim in the law against a generator + dimension. +- **Easy declared states and probes.** A declared state or probe is placed + where it is always true, or is defined by a weaker condition than the state + it names. Its count rises without showing that histories enter the + interesting state. Check each definition against the state that the law + needs. +- **A late or coarse checkpoint.** The comparison runs only at the end of a + history or observes only a summary, so a wrong intermediate observation + disappears. Ask whether a user could see the wrong value before the + checkpoint. +- **A limit that hides in-scope work.** A declared limit, waiver, or + coverage-map entry excludes a legal case that the law promises. Check that + each limit's decision covers exactly what it excludes. +- **A dismissed survivor.** A surviving mutant is called equivalent or out of + scope without an argument from the contract. Require that argument, or a + witness. +- **An invariant guard without an invariant witness.** A throw for a + contradictory collaborator signal or impossible state has no history that + reaches it. Require an invariant witness: a fault-injected step that supplies + the contradictory signal, with the expected observation that production + fails at the boundary before it releases established state or publishes + success. Report supported findings and remaining uncertainty. Do not invent findings, retire laws, or rewrite expectations to make a review come out decisive. diff --git a/.changeset/observer-restart-rewrite.md b/.changeset/observer-restart-rewrite.md new file mode 100644 index 0000000000..94f5629af7 --- /dev/null +++ b/.changeset/observer-restart-rewrite.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Show a row that a restarted sync run writes again after cleanup. A live-query observer (and so `useLiveQuery` in every framework adapter) that stayed subscribed through a Collection's cleanup and restart showed no row when the new sync run wrote a row equal to one it showed before cleanup, or when an on-demand restart reloaded it. The observer's Collection subscription now keeps none of the rows from before cleanup. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 14a0149f81..709477ea61 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,7 +4,7 @@ ## 🔬 Oracle Coverage - + diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 19d590dba9..ee3e4029f8 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -183,6 +183,8 @@ jobs: uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 - name: Build Packages run: pnpm run build + - name: Check that no build carries an oracle probe + run: node scripts/check-dist-probes.mjs - name: Publish Previews run: pnpx pkg-pr-new publish --pnpm --compact './packages/*' --template './examples/*/*' size: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 008983d5c0..0688d7377e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -118,6 +118,8 @@ jobs: run: pnpm --dir packages/db exec vitest --mergeReports .vitest-reports - name: Build packages run: pnpm run build + - name: Check that no build carries an oracle probe + run: node scripts/check-dist-probes.mjs - name: Run Changesets (version or publish) id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 diff --git a/.gitignore b/.gitignore index d1bb2024cd..ab86f2ae7b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,10 +49,11 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* tsconfig.vitest-temp.json -# The part-reach oracle lints scratch programs inside the repository; an -# interrupted run can leave them behind. -/part-reach-scratch-*/ - # Other checkouts and scratch copies. A plain directory here is not part of # this checkout's branch, so the oracle lint hooks must not see its files. /.worktrees/ + +# The gap report CLI: evidence store and workspace bundles, and the harness +# files that a run which died leaves beside the package +.oracle-gaps/ +.oracle-gaps-* diff --git a/AGENTS.md b/AGENTS.md index e9dabc568d..686c3d2d4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ review, where it costs more. RED result, and it proves that the oracle sees the defect. 4. Change production. The oracle gives immediate feedback on each attempt, including attempts that break a neighboring law. -5. Show that a hostile mutant, such as the original defect, fails the oracle. +5. Show that a mutant, such as the original defect, fails the oracle. On this path, you know the fix is correct before you push it. Reviewers find little that is new, so the total work and token spend stay low. Later agents @@ -66,7 +66,7 @@ inherit a clean model of the subsystem that they can extend in place. Change production first, then adjust the oracle until it passes: relax a comparison, add an exclusion, change a model rule to match the new output, or -add one pinned example. The tests pass, but nothing shows that the oracle can +add one fixed history. The tests pass, but nothing shows that the oracle can see the defect class. Auditors find the gap. Each finding costs a fix, a new witness, and another review round, and the next fix often breaks a neighboring law that the oracle still cannot see. @@ -83,7 +83,7 @@ frustration. Work earns extra credit for: - an oracle improvement that has proof: a declared reach, a RED result on the - parent commit, a GREEN result, and a hostile mutant that fails. + parent commit, a GREEN result, and a mutant that fails. - a model that stays independent of production and short, with each rule tied to its authority. - a production change that is clean and light in shipped bytes, especially one @@ -97,23 +97,22 @@ is not ready: - **Production first:** a production change in an oracle-owned area without a RED result from the extended oracle on the code before the change. - **Model hacking:** a model rule changed to match production output without a - stated authority (a contract document and section, or a maintainer - decision). Production output is evidence. It is not authority. + stated authority (a contract document and section, or a maintainer decision). + Production output is evidence. It is not authority. - **Unreachable claims:** a closure claim for a state that generated histories do not reach, or a reach that nobody measured. - **Quiet exclusions:** a skipped, narrowed, or relaxed comparison without an entry in the [coverage map](docs/contributing/oracle-coverage.md) that names - the owner and the maintainer decision. -- **Pinned-only coverage:** a named example where the law needs a generator - dimension. + the oracle owner and the maintainer decision. +- **Fixed-history-only coverage:** a named fixed history where the law needs a + generator dimension. - **Unit tests inside oracles:** ordinary examples or concrete type assertions - placed in an oracle file. A fixed regression must replay the shared model, - production driver, and comparison without its own expected answer. + placed in an oracle file. A fixed regression must run through the shared + model, production driver, and comparison without its own expected answer. - **"Pre-existing" as a dismissal:** a reachable counterexample that an agent - labels as older than the change, but does not fix, record in the coverage - map with its trace, or bring to the maintainer for a decision. -- **No mutant:** a closure claim without a hostile mutant that fails the - oracle. + labels as older than the change, but does not fix, record in the coverage map + with its trace, or bring to the maintainer for a decision. +- **No mutant:** a closure claim without a mutant that fails the oracle. ### The evidence block @@ -124,14 +123,16 @@ includes this block: - **Reach:** the declared state and how many generated histories reached it. - **RED:** the command and the failure on the parent commit. - **GREEN:** the command and the result after the change. -- **Mutant:** the hostile mutant and how the oracle rejected it. +- **Mutant:** the mutant and how the oracle rejected it. - **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 +The `oracle/` lint rules report the shortcuts that a syntactic check can see. +They come from the `@tanstack/eslint-plugin-oracles` package (TanStack/oracles), +and `scripts/oracle-lint-settings.mjs` holds this repository's settings for +them. 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 @@ -146,7 +147,7 @@ Restore it, or explain the reason in the pull request. 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. +request to TanStack/oracles. ## Required reading: live-query materialization @@ -169,10 +170,10 @@ that combines, splits, or does not correspond to production concepts. Some oracle files are also the shortest documentation for their subsystem. Before changing a covered contract or its machinery, use -`docs/contributing/oracle-coverage.md` to find the primary executable owner and -read its stated limits. Do not infer authority from a filename alone. +`docs/contributing/oracle-coverage.md` to find the primary executable oracle +owner and read its stated limits. Do not infer authority from a filename alone. -Read these stable entry points before the narrower owner: +Read these stable entry points before the narrower oracle owner: - For correlated live-query materialization, read the architecture document required above. @@ -186,8 +187,8 @@ Read these stable entry points before the narrower owner: - For TrailBase lifecycle work, read `packages/trailbase-db-collection/tests/ORACLE.md`. - For cross-framework behavior, read the shared contract under - `packages/db/tests/conformance/` and the receiving framework's driver. One - framework's scheduling cut does not prove another's. + `packages/db/tests/conformance/` and the receiving framework's production + driver. One framework's scheduling cut does not prove another's. The opening prose states the contract. The small model states the expected behavior. The production driver and comparison check whether the implementation @@ -557,8 +558,8 @@ const dependentBuilders = [] // Accurately describes dependents ### Required reading: oracle tests Before designing, changing, or reviewing an oracle or generated-history test, -read [Oracle Testing Rubric](docs/contributing/oracle-rubric.md). -Use the [coverage map](docs/contributing/oracle-coverage.md) to find an existing +read [Oracle Testing Rubric](docs/contributing/oracle-rubric.md). Use the +[coverage map](docs/contributing/oracle-coverage.md) to find an existing oracle owner and its limits before adding another model. The guide explains testing methods; it does not authorize new product behavior or retire existing laws. @@ -632,19 +633,19 @@ at specific checkpoints. Extend the primary oracle so it reaches the reported trace and nearby legal histories that distinguish the repair from plausible wrong designs. Use a separate oracle owner when another boundary needs a different model. Show that -the check fails on the original implementation or a hostile mutant at the +the check fails on the original implementation or a mutant at the intended checkpoint, then passes with the fix. -At closeout, state what the evidence covers. Record remaining in-scope -histories or paths in the oracle coverage map with an owner and needed witness. -A reachable in-scope counterexample keeps the class open. Passing random runs -is not a universal proof. A bug-fix pull request is not ready while a known +At closeout, state what the evidence covers. Record remaining in-scope histories +or paths in the oracle coverage map with an oracle owner and needed witness. A +reachable in-scope counterexample keeps the class open. Passing random runs is +not a universal proof. A bug-fix pull request is not ready while a known in-scope part of the declared bug class remains open. Every bug-fix pull request must show how it evaluated and closed the declared bug class. State the violated law and its authority. Name the legal histories, production paths, and public observation checkpoints that the oracle exercises. -Include the original or hostile-mutant RED result and the repaired GREEN result. +Include the original or mutant RED result and the repaired GREEN result. State any remaining limits. A passing reproduction for one trace does not satisfy this requirement. @@ -658,18 +659,19 @@ that an oracle. Both copies can encode the same wrong assumption. - Derive expected behavior from public contracts, documented prior behavior, mathematical laws, or a separately specified reference model. - Keep the reference model structurally different from production. Do not import - the production helper or reuse its classifications to compute expected results. -- Preserve existing contract tests unless a product or design decision explicitly - changes the contract. Rewriting a passing expectation to match new production - behavior is a design review, not routine test maintenance. + the production helper or reuse its classifications to compute expected + results. +- Preserve existing contract tests unless a product or design decision + explicitly changes the contract. Rewriting a passing expectation to match new + production behavior is a design review, not routine test maintenance. - When production work suggests an oracle change, compare the old and new semantics with counterexamples before editing the oracle. -- Use hostile mutants to prove the oracle rejects plausible wrong designs, - including the mistake production currently makes. A green oracle without a - demonstrated kill is weak evidence. -- Use process grammar to explore lifecycle paths, and design grammar to challenge - the oracle's reference semantics. More generated traces cannot repair a wrong - reference model. +- Use mutants to prove the oracle rejects plausible wrong designs, including the + mistake production currently makes. A green oracle without a demonstrated kill + is weak evidence. +- Use process grammar to explore lifecycle paths, and design grammar to + challenge the oracle's reference semantics. More generated traces cannot + repair a wrong reference model. ### Require Applicable Oracle Coverage @@ -678,30 +680,31 @@ stronger form of coverage than a unit test for one specific example. Every production change that adds, removes, or changes observable behavior must extend the primary oracle when that oracle can express the governing law. Add the missing model rule, generator dimension, state transition, or observable -assertion; adding more pinned examples alone does not generalize the oracle. Do +assertion; adding more fixed histories alone does not generalize the oracle. Do not submit only an isolated unit test for behavior owned by an applicable oracle. An internal refactor does not require new oracle coverage when existing applicable oracles already exercise the unchanged contract. Applicability follows the contract ownership recorded in -`docs/contributing/oracle-coverage.md`, not the limits of the owner's current -histories, production paths, or observations. A missing history, path, or -observation is a coverage gap in the primary owner. Add a separate oracle owner -only when the boundary requires a different independent judgment. Add a -receiving witness when a claim crosses an adapter, framework, provider, or other -integration boundary. +`docs/contributing/oracle-coverage.md`, not the limits of the oracle owner's +current histories, production paths, or observations. A missing history, path, +or observation is a coverage gap in the primary oracle owner. Add a separate +oracle owner only when the boundary requires a different independent judgment. +Add a receiving witness when a claim crosses an adapter, framework, provider, or +other integration boundary. For a bug fix, use a focused regression to isolate and shrink the failure, then -keep it as a replay example for the broader oracle. Verify that the expanded +keep it as a fixed regression for the broader oracle. Verify that the expanded oracle fails without the fix and passes with it. Keep valuable unit tests as supplemental coverage, but do not treat them as a substitute for applicable oracle coverage. If no existing oracle applies, create one when the behavior admits a practical independent model. For a change that is not a bug fix, a focused test alone is acceptable only when an oracle is not practical for the behavior. The pull request must identify the relevant coverage-map entry and -explain why extending its owner, creating a new independent oracle, or adding a -receiving witness is not practical. Bug fixes cannot use this focused-test-only -exception. They must provide evidence that closes the entire declared bug class. +explain why extending its oracle owner, creating a new independent oracle, or +adding a receiving witness is not practical. Bug fixes cannot use this +focused-test-only exception. They must provide evidence that closes the entire +declared bug class. ### Keep Unit Tests Out of Oracles @@ -730,14 +733,14 @@ Modules that define the model, checker, or its history grammar also qualify. Identify the actual mechanism before renaming a file. A coverage-map entry, contract comment, or collection of fixed assertions is not sufficient evidence. -An oracle file holds only oracle tests. It declares its check, model, and history -grammar with `declareOracle` from `packages/db/tests/oracle-declaration.ts`. A -type-level oracle names its model types in `typeModel`. Each -part is optional. Every test in the file must use a declared part, directly -or through a driver, or run a generated property that states its law inline. -A test that compares production with a hand-written value is a unit test: -put it in an ordinary test file. The `oracle/no-unit-test` lint rule reports -a test that does neither. +An oracle file holds only oracle tests. It declares its check, model, and +history grammar with `declareOracle` from +`packages/db/tests/oracle-declaration.ts`. A type-level oracle names its model +types in `typeModel`. Each part is optional. Every test in the file must use a +declared part, directly or through a production driver, or run a generated +property that states its law inline. A test that compares production with a +hand-written value is a unit test: put it in an ordinary test file. The +`oracle/no-unit-test` lint rule reports a test that does neither. Ordinary example tests, type assertions, fixtures, registration wrappers, and runner utilities keep their ordinary names. A module that only drives production diff --git a/docs/contributing/designs/source-failure-and-health/PLAN.md b/docs/contributing/designs/source-failure-and-health/PLAN.md index 68ac6dbf9e..3a1eba1504 100644 --- a/docs/contributing/designs/source-failure-and-health/PLAN.md +++ b/docs/contributing/designs/source-failure-and-health/PLAN.md @@ -153,7 +153,7 @@ names and shapes. `subscribeWs` closes without retrying. A correct reconnect would have to subscribe again and re-list every active subset. The maintainer decided to leave reconnect to the TrailBase maintainers. - - [ ] Update the stream-end lane in `tests/lifecycle-oracle.property.test.ts` + - [ ] Update the stream-end grammar in `tests/lifecycle-oracle.property.test.ts` (added in #2126) from "rows stay, later changes are not delivered" to also expect a final health error. - [x] **RxDB** (`rxdb-db-collection/src/rxdb.ts`): no change. V10 is @@ -198,14 +198,14 @@ names and shapes. release. Extend the topology beyond the TLA model: a collection reached by two aliases, includes with per-row children, and pooled views. - [ ] Model D (`includes-temporal-oracle.test.ts`): rework the source-error - lane to the final-error law. Start from `evidence/ + grammar to the final-error law. Start from `evidence/ model-d-source-error-grammar.patch` (the C4, C5, and C6 grammar dimensions: truncate during the error, ready → error, exact acquisitions, and every `source-ready` position). Under the new law, `source-ready` after `source-error` in one sync run is illegal, so those histories become "restart, then recover". - [ ] Shared framework conformance suite (`packages/db/tests/conformance/`): - a health scenario, so all five drivers check `health` the same way. + a health scenario, so all five production drivers check `health` the same way. - [ ] Adapter witnesses: each adapter reports post-ready failures through health and keeps its rows. Electric adds SDK give-up cases through the real SDK (missing headers, retry exhaustion). @@ -246,7 +246,7 @@ names and shapes. - [ ] Package READMEs that mention `isError` (for example `packages/angular-db/README.md`). - [ ] `docs/contributing/oracle-coverage.md`: the new health owner, and the - Model D source-error lane. + Model D source-error grammar. - [ ] `docs/contributing/oracle-reviews/progressive-source-readiness.md`: record the decided law and the companion fixes (#2126, #2127, #2128). #2129 carried this update and is being replaced. diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index cc9c66f1e1..d441ae4aaf 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -187,7 +187,7 @@ crossing subsystem boundaries; these comparisons are not interchangeable. | Term | Meaning | | --- | --- | -| D2 value identity | The value domain compared by `hash` and `equalHashValues`, including their documented reference leaves. A matching hash digest alone does not establish identity. The [identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts) defines its bounded value rules. Map/Set insertion-order sensitivity is pinned current behavior, not a universal D2 operator contract. | +| D2 value identity | The value domain compared by `hash` and `equalHashValues`, including their documented reference leaves. A matching hash digest alone does not establish identity. The [identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts) defines its bounded value rules. Map/Set insertion-order sensitivity is current behavior that tests assert, not a universal D2 operator contract. | | change-event equality | The `sameRowValue` comparison used for Collection change-event suppression. Its supported domain preserves record prototypes and distinguishes signed zero, while ignoring Map/Set insertion order, RegExp `lastIndex`, and array holes versus `undefined`. The [deep-equality oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts) defines its shared value-class rules; the eager live-source conformance owner also checks row-value fidelity. | | draft equality | The comparison that recognizes a reverted draft value. Its [revert oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts) preserves Map/Set insertion order, RegExp `lastIndex`, and array holes, with explicit snapshot rules for class instances. Native mutator methods have separate change-tracking laws. | | persisted-snapshot equality | The value-shape comparison used before skipping a reconciliation reload. It preserves array holes, enumerable array properties, and exact nested prototypes. Equal keyed class, null-prototype, or cross-realm records with different prototypes require reload; Map and Set insertion order remains significant. | @@ -205,17 +205,41 @@ crossing subsystem boundaries; these comparisons are not interchangeable. | observation | One recorded public fact. | | trace | An ordered sequence of observations. | | checkpoint or observation cut | The exact point where the contract requires comparison. | -| oracle replay | Re-running a recorded oracle failure from its seed, shrink path, actions, or other replay inputs. Do not use it for runtime truncate replay, retry, or a new random campaign. | +| oracle replay | Re-running a recorded oracle failure from its seed, shrink path, actions, or other replay inputs. Do not use it for runtime truncate replay, retry, or a new random campaign. A run with a chosen seed and no path is a reseeded campaign, not a replay. | | projection or abstraction | The parts of concrete state retained for one judgment. | | refinement check | An executable comparison at a named checkpoint that accepts only production observations allowed by the model. A sampled check is not a proof for every behavior. | | partial oracle | An oracle that judges only named parts of the contract. | -| reach witness | Evidence that the production driver reached the claimed boundary. | +| reach witness | Evidence that the production driver reached the claimed boundary, such as a real host, a provider call, or a protocol step. It concerns the production path. Compare reach, which concerns the history grammar. | +| declared state | A state that a law depends on. The oracle property names it, and its reach is counted. | +| reach | The number of histories that entered a declared state. A missed required reach fails the run. Reach counts states that were actually entered, not labels that the grammar assigns. Compare reach witness. | +| state probe | A development-only call in production code, `probing && globalThis.__oracleProbe?.('package#name', condition)`, where the enclosing function first declares `const probing = typeof process === 'undefined' ? false : process.env.NODE_ENV !== 'production'`, at the point where production decides whether it enters a declared state. The name is a string literal; the condition is a side-effect-free expression that is truthy when production enters the state. The declared state names the probe as `{ name, probe }`. The model and the check never see its hits. Compare a coverage probe, which only has to be hit. | +| probe reached | A state probe was called in a history's window, whatever its condition. Compare entered. | +| entered (probe-bound state) | A probe-bound declared state is entered in a history when its state probe was called with a truthy condition in that history's window. Compare probe reached: a probe can be reached without entering its state. Compare reach, which counts the histories that entered a declared state. | +| prediction | What the property's `predict` says, from the history alone, about whether a history enters each probe-bound declared state. It is part of the model and never reads the trace. | +| reach agreement | The law that a history's predicted entries of probe-bound declared states match the probes' entries, in both directions. A probe-bound state counts as reached only where they agree. A predicted entry that did not happen says whether its probe was never reached or was reached with a false condition. A call after the history's window closed is an error, because the driver must await the work it starts. | | versioned review record | Review evidence stored at a stable location with the exact reviewed head and append-only entries, so later edits cannot silently change the recorded verdict. | -| mutant | A deliberate wrong answer or implementation used to test oracle sensitivity. | +| mutant | A deliberate wrong answer or implementation used to test oracle sensitivity: a corrupted trace, a hostile production driver, or a production code change. Do not call it a hostile mutant; every mutant is hostile. | +| calibration | A test that applies a mutant and requires the check to report a violation of the named law. A calibration skips a candidate whose unaltered trace already fails. | +| killed or survived | A mutant is killed when the check reports a violation of the named law. A crash, timeout, or setup error is recorded separately and is not a kill. A mutant that passes survived. | | fault injection | A deliberate runtime failure, delay, cancellation, or malformed response used to test a system boundary. | | shrinking | Reducing a failing generated history while preserving the failure. | | bounded exhaustiveness | Executing every case inside stated finite bounds. | | held-out challenge | A challenge not used to shape the tested design. After it guides a change, retain it but stop calling it held out. | +| oracle property | One registered unit that checks laws. It has an id, a law, a history grammar, a reference model or a differential law, production drivers, a refinement check, and campaigns. | +| generated history | A history drawn from the history grammar by a seeded generator. | +| fixed history | A named history that is a member of the history grammar. It is checked at registration and runs on every campaign. A fixed regression is a fixed history kept from a found failure. Do not call it a pin, and do not call it an example, which this repository uses for ordinary example-based tests. fast-check's `examples` option is one mechanism for running fixed histories. | +| constructed history | A history that a declared builder produces inside the history grammar so that it enters a named declared state. It guarantees that state's reach in every campaign. Do not call it a segment. | +| campaign | One run plan over an oracle property. A fixed-seed campaign uses a documented seed. A random campaign draws a seed and logs it. A reseeded campaign is a random campaign whose seed was chosen to reproduce one. Do not call it a lane. | +| campaign seed and replay path | fast-check's coordinates for re-running one campaign and one shrunk failure. Qualify the seed as a campaign seed; a hydration seed is a product term. | +| run budget | The base number of generated histories in a campaign, before the run multiplier. A lowered or removed run budget is reported. Qualify it; a retry budget is a product term. | +| verdict | The result of a refinement check: pass, or a violation that names its checkpoint, law, and difference. | +| differential law | A law that compares observations from two production paths at a checkpoint where the contract owes the same answer. It uses no reference model. | +| receiving path | A further production path, such as a real host, SDK, browser, or provider, that shares an oracle's reference model and refinement check. A receiving witness is the evidence that the path ran and was compared. | +| oracle owner | The file that owns a law's judgment, as listed in the coverage map. Always qualify it; owner alone is a product term. | +| decision record | A recorded maintainer decision with an id, date, scope, and text. A law or a limit cites it as its authority. Production output is never authority. | +| limit | A declared statement of what an oracle does not establish, such as a state, dimension, production path, or comparison that it does not cover. It names an oracle owner and a decision record and reports whether it was in effect. A tool-detected hole is a gap, not a limit. | +| gap | A hole that a tool or review detects, such as a declared state that was never reached or a mutant that survived. A gap is resolved by an oracle change or recorded as a limit. Qualify it; a sequence gap is a product term. | +| self-oracle | An oracle whose subject is the oracle framework itself. | ## Grammar rules @@ -253,7 +277,9 @@ Use `settled` for a promise that fulfilled or rejected. Use `complete` for named coverage, content, history, or a non-promise operation only when no stronger boundary term applies. Use `success` only for fulfillment. Qualify overloaded words such as `state`, `request`, `snapshot`, `session`, `owner`, `release`, `recovery`, and -`generation` at first use. +`generation` at first use. In oracle prose, qualify `driver` (production +driver), `seed` (campaign seed), `owner` (oracle owner), `coverage` (oracle +coverage), and `budget` (run budget). ## Model alignment rule diff --git a/docs/contributing/instruments/adversarial-review.md b/docs/contributing/instruments/adversarial-review.md index 7340cd1477..0afcd1f143 100644 --- a/docs/contributing/instruments/adversarial-review.md +++ b/docs/contributing/instruments/adversarial-review.md @@ -14,7 +14,7 @@ levels; they are different questions, not a mandatory sequence: | Level | Attack | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Enforcement | Find a legal history or wrong output that the model, generator, driver, or recorder cannot distinguish. Can the intended production path remain unexercised while the test passes? | +| Enforcement | Find a legal history or wrong output that the model, generator, production driver, or recorder cannot distinguish. Can the intended production path remain unexercised while the test passes? | | Formulation | Find an omitted precondition, excessive guarantee, wrong checkpoint, or ambiguous observation. Try a correct alternative implementation as well as a wrong one. | | Suitability | Question whether the promised behavior is desirable, safe, or conventional. Name the external contract, standard, user consequence, or engineering reason that makes the objection applicable. | | Composition | Find obligations that conflict, duplicate each other, or require a shared ordering or authority rule. Use the tension or restructuring card if that is the material uncertainty. | @@ -34,7 +34,7 @@ this table to locate the repair: | The reference predicts behavior the accepted law does not allow. | Reference model. | | An accepted law over-promises, under-promises, or conflicts with another law. | Authorized contract decision, then model and test updates. | | The law needs information the production path lacks at the promised checkpoint. | Provider, protocol, architecture, or an authorized contract decision. | -| The check rejects an allowed intermediate observation or accepts a wrong one. | Evaluation: checkpoint, history grammar, driver, recorder, or comparison. | +| The check rejects an allowed intermediate observation or accepts a wrong one. | Evaluation: checkpoint, history grammar, production driver, recorder, or comparison. | | Correct behavior still fails the user's need. | Requirement and its suitability for the intended use. | These locations can overlap. Name the evidence for each before choosing a @@ -52,12 +52,12 @@ the evidence that could decide between them, and a possible repair condition. Check that the attack is within scope and that its standard actually applies. An outside-domain case may motivate a scope change; label that proposal. -When execution is authorized and practical, run the discriminating experiment. -A hostile control must reach the intended comparison to demonstrate rejection. -Use the oracle guide's calibration and mutant-outcome rules. A source-level -argument can remain useful when execution is unavailable; label it accurately. -For a systematic search across plausible wrong implementations, use the -[mutant gap hunt](mutant-gap-hunt.md) to design semantic edits and classify +When execution is authorized and practical, run the discriminating experiment. A +wrong-result control must reach the intended comparison to demonstrate +rejection. Use the oracle guide's calibration and mutant-outcome rules. A +source-level argument can remain useful when execution is unavailable; label it +accurately. For a systematic search across plausible wrong implementations, use +the [mutant gap hunt](mutant-gap-hunt.md) to design semantic edits and classify survivors. **Return:** supported defects, design objections, and untested hypotheses as diff --git a/docs/contributing/instruments/law-restructuring.md b/docs/contributing/instruments/law-restructuring.md index c69c3a8042..6b7831c367 100644 --- a/docs/contributing/instruments/law-restructuring.md +++ b/docs/contributing/instruments/law-restructuring.md @@ -9,28 +9,29 @@ recovering the obligations they represent. ## Operation -1. Decompose the laws into traceable obligations, conditions, distinctions, - and promised observations. Check that these parts can reconstruct each - original claim; invented categories can lose the essential constraint. - If public wording and an executable owner disagree, retain both claims and - the unresolved authority. A passing test alone cannot retire public wording. +1. Decompose the laws into traceable obligations, conditions, distinctions, and + promised observations. Check that these parts can reconstruct each original + claim; invented categories can lose the essential constraint. If public + wording and an executable oracle owner disagree, retain both claims and the + unresolved authority. A passing test alone cannot retire public wording. 2. Propose a different organization and state the operation: merge under a common principle, split by authority or checkpoint, replace a concept, or retire an obligation with an explicit reason. Fewer words or model fields alone do not establish improvement. -3. Map every original obligation to where it is preserved, narrowed, strengthened, - rejected, or still unresolved in the candidate. State what insight originally - made the obligation necessary. A claim may be defeated, but needs a reason - and a design decision rather than disappearing from the mapping. +3. Map every original obligation to where it is preserved, narrowed, + strengthened, rejected, or still unresolved in the candidate. State what + insight originally made the obligation necessary. A claim may be defeated, + but needs a reason and a design decision rather than disappearing from the + mapping. 4. Derive old and proposed predictions for a witness exercising each distinction that the reorganization could erase. Compare both allowed and forbidden - outcomes. Check whether the change accidentally forbids a valid implementation - or admits one of the formerly rejected wrong designs. + outcomes. Check whether the change accidentally forbids a valid + implementation or admits one of the formerly rejected wrong designs. 5. When an independent preservation check is useful and authorized, give a fresh agent the original obligation, its sources, and one candidate. Hide sibling candidates and the author's preferred outcome. Ask what insight survives, - what is defeated, and what smallest repair is needed. Without that separation, - describe the result as a trace audit, not independent validation. + what is defeated, and what smallest repair is needed. Without that + separation, describe the result as a trace audit, not independent validation. **Return:** the proposed organization, the old-to-new obligation mapping, witnesses and their evidence status, losses, and unresolved design decisions. @@ -38,8 +39,8 @@ Keeping the existing laws separate is a valid result. **Control:** reconstruct each original obligation from the candidate and check its discriminating witness. A semantic change may be justified, but is not an -equivalent refactor. Merging prose does not require merging executable owners -whose models or production boundaries differ. +equivalent refactor. Merging prose does not require merging executable oracle +owners whose models or production boundaries differ. **Distortion:** neat categories that erase obligations, speculative connections presented as established, or preserving familiar wording instead of useful diff --git a/docs/contributing/instruments/mutant-gap-hunt.md b/docs/contributing/instruments/mutant-gap-hunt.md index 6b71a0ad22..9e05680b5a 100644 --- a/docs/contributing/instruments/mutant-gap-hunt.md +++ b/docs/contributing/instruments/mutant-gap-hunt.md @@ -12,8 +12,8 @@ distinguish a mistake. Keep test bodies and assertions out of the design brief. ## Operation 1. **Design plausible mistakes before reading the tests.** Start with semantic, - type-plausible edits near the changed boundaries: a lost operand, wrong - prior value, premature clear, duplicated event, changed ordering, or skipped + type-plausible edits near the changed boundaries: a lost operand, wrong prior + value, premature clear, duplicated event, changed ordering, or skipped callback. Turn each open review question into a candidate. If code is called dead, try removing it. In a larger hunt, spread candidates across the affected files and boundaries rather than clustering where edits are easy. @@ -39,8 +39,8 @@ distinguish a mistake. Keep test bodies and assertions out of the design brief. source; it is no oracle gap. If that freedom seems undesirable, take the question to law discovery or adversarial review at the suitability level. Grade an equivalence claim at a named revision: equivalent by invariant, - equivalent only on instrumented reached histories with a positive control, - or evidence-limited and unresolved. The last is not an equivalence verdict. + equivalent only on instrumented reached histories with a positive control, or + evidence-limited and unresolved. The last is not an equivalence verdict. Check all relevant entry paths, including reentry and environment-specific branches; one unreachable path does not establish general equivalence. Probe the nearest unusual legal history before accepting an unreachability claim. @@ -53,11 +53,11 @@ distinguish a mistake. Keep test bodies and assertions out of the design brief. clean git status in its checkout. 4. **Repair the oracle chain, when authorized.** Locate the missing law, model rule, history action, production path, recorder, or assertion. Extend the - primary owner where it can express the distinction. Show that the extension - rejects the motivating mutant and a nearby plausible wrong design without - rejecting a legal alternative. For a bug on the baseline, demonstrate the - production RED before its repair. Update the coverage map when the owner's - reach or limits change. For a claimed oracle repair, follow + primary oracle owner where it can express the distinction. Show that the + extension rejects the motivating mutant and a nearby plausible wrong design + without rejecting a legal alternative. For a bug on the baseline, demonstrate + the production RED before its repair. Update the coverage map when the oracle + owner's reach or limits change. For a claimed oracle repair, follow [ORC-012](../oracle-rubric.md#orc-012-review-evidence): at closeout, verdict-critical evidence outside the executable oracle belongs in a versioned review record tied to the exact reviewed head. This card also asks diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 537ab3f1cc..c66b169a7b 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -148,34 +148,40 @@ The Convex package has these independent judgments: Each owner runs matching fixed and random campaigns and accepts explicit seed/path replay through their CONVEX_*_ORACLE_SEED/PATH environment variables. -Each normal lane has a fresh reach ledger, fails if any declared state has fewer -than one reached history, and prints its counts as `ORACLE_REACH` JSON. Explicit -path replays run directly with one selected fast-check run and bypass campaign -minima; shrink-path reconstruction can evaluate the callback more than once. -Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination has separate CONVEX_PAGINATION_ROWS, CONVEX_PAGINATION_WORK, and CONVEX_PAGINATION_BOUND selectors; -offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay uses -CONVEX_VALUES_ORACLE; ownership uses CONVEX_OWNERSHIP_ORACLE. - -`packages/convex-db-collection/e2e/backend-oracle.test.ts` receives these contracts -at an actual isolated local Convex backend using both Convex 1.47.0 SDK clients. -Its row-bound rejection is a fixed test in -`packages/convex-db-collection/e2e/backend.test.ts` (fixed tests; no oracle owner -(tracked in #2264)). -It compares pagination with a separately evaluated unpaginated server query, -including real server-generated split responses. Its durable driver runs fixed -and random insert/update/delete histories through SIGKILL after disk admission and, optionally, -a second SIGKILL after server acceptance but before recording outbox completion. -It requires no offline provider call, restored Collection rows, and one server -receipt after replay with the original key and the expected final server rows. -Native payload variation covers inserts and updates. A separate non-idempotent application counter must equal one, and the receipt key must match the on-disk durable key. Restored Collections are compared on every application field, including native payloads. Atomic batches and multi-tab leadership -remain outside this backend witness. Replay uses -CONVEX_BACKEND_INSERT_ORACLE_SEED/PATH, CONVEX_BACKEND_UPDATE_ORACLE_SEED/PATH, or CONVEX_BACKEND_DELETE_ORACLE_SEED/PATH, with matching operation test selection. The command owns backend startup and shutdown. -The offline engine's interruption, settlement, storage, and leadership owners -remain primary for those laws; this is their Convex receiving witness. -Generated bundles constructively cover known and uncertain acknowledgement for -each native payload class while retaining six actual process histories per -operation per lane (36 total). Reach is credited at observed disk admission, -server-acceptance and full replay comparisons, not from requested fixture inputs. +Each normal campaign has a fresh reach ledger, fails if any declared state has +fewer than one reached history, and prints its counts as `ORACLE_REACH` JSON. +Explicit path replays run directly with one selected fast-check run and bypass +campaign minima; shrink-path reconstruction can evaluate the callback more than +once. Snapshot replay uses CONVEX_SNAPSHOT_ORACLE; pagination has separate +CONVEX_PAGINATION_ROWS, CONVEX_PAGINATION_WORK, and CONVEX_PAGINATION_BOUND +selectors; offline bridge replay uses CONVEX_OFFLINE_ORACLE; native value replay +uses CONVEX_VALUES_ORACLE; ownership uses CONVEX_OWNERSHIP_ORACLE. + +`packages/convex-db-collection/e2e/backend-oracle.test.ts` receives these +contracts at an actual isolated local Convex backend using both Convex 1.47.0 +SDK clients. Its row-bound rejection is a fixed test in +`packages/convex-db-collection/e2e/backend.test.ts` (fixed tests; no oracle +owner (tracked in #2264)). It compares pagination with a separately evaluated +unpaginated server query, including real server-generated split responses. Its +durable driver runs fixed and random insert/update/delete histories through +SIGKILL after disk admission and, optionally, a second SIGKILL after server +acceptance but before recording outbox completion. It requires no offline +provider call, restored Collection rows, and one server receipt after replay +with the original key and the expected final server rows. Native payload +variation covers inserts and updates. A separate non-idempotent application +counter must equal one, and the receipt key must match the on-disk durable key. +Restored Collections are compared on every application field, including native +payloads. Atomic batches and multi-tab leadership remain outside this backend +witness. Replay uses CONVEX_BACKEND_INSERT_ORACLE_SEED/PATH, +CONVEX_BACKEND_UPDATE_ORACLE_SEED/PATH, or +CONVEX_BACKEND_DELETE_ORACLE_SEED/PATH, with matching operation test selection. +The command owns backend startup and shutdown. The offline engine's +interruption, settlement, storage, and leadership owners remain primary for +those laws; this is their Convex receiving witness. Generated bundles +constructively cover known and uncertain acknowledgement for each native payload +class while retaining six actual process histories per operation per campaign +(36 total). Reach is credited at observed disk admission, server-acceptance and +full replay comparisons, not from requested fixture inputs. `packages/convex-db-collection/e2e/public-pagination-oracle.test.ts` receives the finite pagination contract through the actual public `get-convex/convex-demos` @@ -312,22 +318,23 @@ the earlier injected-edge control is not claimed as a rejected waiting strategy. `lazy-paging-native-receipt-oracle.property.test.ts` feeds actual native batches and core receipts into that same scientific model. Independently authored -required versions absent at acquisition have unique supplying batches, which -can establish two callers. It covers cached zero new edges, a shallow cached -caller behind a deeper held peer, canceled/successful shared callers, and explicit +required versions absent at acquisition have unique supplying batches, which can +establish two callers. It covers cached zero new edges, a shallow cached caller +behind a deeper held peer, canceled/successful shared callers, and explicit cache revalidation to a new version. Early or delayed cached settlement is -permitted; responsive post-drain completion is required. Its hostile native -receiver returns the already applied startup receipt instead of the actual -held receipt. Native attribution here excludes idempotent/superseding facts, +permitted; responsive post-drain completion is required. Its mutant native +receiver returns the already applied startup receipt instead of the actual held +receipt. Native attribution here excludes idempotent/superseding facts, reentrant commit observations and cleanup abandonment; the older controlled -receiver retains idempotent receipt coverage and its all-responses-first profile. +receiver retains idempotent receipt coverage and its all-responses-first +profile. `lazy-paging-version-model-oracle.ts` owns coherent authored revisions and their monotone public frontier. `lazy-paging-version-oracle.property.test.ts` checks every source publication after full lazy acquisition, with multiple accepted revisions held behind real optimistic transactions, both SDK shapes and skipped intermediate revisions. Old complete values may remain while held; rollback -and mixed versions fail even when final values are correct. The two hostile +and mixed versions fail even when final values are correct. The two mutant controls inject wrong public observations; they calibrate the recorder/model, not a production pager mutation. This profile keeps keys/order stable. General membership changes and partial-prefix retention retain their existing owners. @@ -409,18 +416,18 @@ does not discharge an unobserved receipt. The shared `LazyTerminalGraphModel` supplies the old graph judgment to both controlled and backend receivers. Both new families run matching fixed/random campaigns, direct seed/path replay, -and both SDKs in every history. Selectors are `CONVEX_REVALIDATION_ORACLE_SEED/PATH` -and `CONVEX_BOUNDARY_ORACLE_SEED/PATH`; `CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER` -expands both campaign budgets equally. Standard package oracle scripts discover -them; the existing browser CI lane runs the backend receiver via `test:e2e`. -Calibration distinguishes unchanged-content application shortcuts, lost native -deletions, and a separate stale-publication perturbation that ends with correct -rows. Exact commands, source hashes and outcome classifications are in the -record. This does not close arbitrary scheduling, rank ties in the new backend -family, provider errors before acceptance, adversarial stale real-SDK callbacks, -same-run authoritative truncate, or React rendering boundaries. Those retain -their existing owners; the controlled failure family remains broader than its -new backend receiving subset. +and both SDKs in every history. Selectors are +`CONVEX_REVALIDATION_ORACLE_SEED/PATH` and `CONVEX_BOUNDARY_ORACLE_SEED/PATH`; +`CONVEX_LAZY_ORACLE_RUNS_MULTIPLIER` expands both campaign budgets equally. +Standard package oracle scripts discover them; the existing browser CI lane runs +the backend receiver via `test:e2e`. Calibration distinguishes unchanged-content +application shortcuts, lost native deletions, and a separate stale-publication +perturbation that ends with correct rows. Exact commands, source hashes and +outcome classifications are in the record. This does not close arbitrary +scheduling, rank ties in the new backend family, provider errors before +acceptance, adversarial keys stale real-SDK callbacks, same-run authoritative +truncate, or React rendering boundaries. Those retain their existing owners; the +controlled failure family remains broader than its new backend receiving subset. The [application/cancellation follow-up](oracle-reviews/2026-10-27-convex-lazy-app-abort.md) adds three receiving families motivated by issue #2269. `LazyWriteModel` in @@ -461,11 +468,12 @@ withheld delivery; no-demand delivery accepts no rows. Retired success/error callbacks after each successful replay cannot resurrect rows, poison current sync or read a retired React watch. Native Convex does not initiate these truncates; the driver supplies that core input. This is no claim about actual -SDKs emitting adversarial stale callbacks or about bounded work during replay. -The existing receipt model still owns accepted writes. This checkout's older -core waits for uncancelable baselines; #2269's immediate-core-abort decision -belongs to integration with unmerged #2168. The new family exercises their -shared cancellable-adapter obligation without silently changing either law. +SDKs emitting adversarial keys stale callbacks or about bounded work during +replay. The existing receipt model still owns accepted writes. This checkout's +older core waits for uncancelable baselines; #2269's immediate-core-abort +decision belongs to integration with unmerged #2168. The new family exercises +their shared cancellable-adapter obligation without silently changing either +law. All three families run fixed/random campaigns with direct seed/path replay and declared reach. The follow-up records three rejected production decisions @@ -560,15 +568,15 @@ that test identifiers must copy production's private data structures. | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ordered relations and BTree | Complete | The signed top-K relation, BTree/Map refinement model, and DBSP incrementalization laws are literate. | | D2 operator work | `packages/db-ivm/tests/d2-work.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | A finalized 65-branch graph receives one direct input at the first, middle, or last branch, then a second input on a different branch. The test compares exact forwarded messages and operator invocations after each `run()`: branches without queued input perform no operator work. A connected reversed-registration pair must drain in the same run, and an empty unfinalized graph rejects. The old unconditional loop fails with 64 idle calls per turn. This bounded work check does not establish elapsed latency, fused operators, arbitrary graph topologies, or provider-level live-query work. | -| Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a pinned witness. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has pinned witnesses only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | +| Includes and publication | Complete, [facade rollback](https://github.com/TanStack/db/blob/main/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts) ([review](oracle-reviews/perf-lazy-bucket-rollback-copy.md)) | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. A failed facade flush restores every facade it wrote, disposes the facades it created, publishes no event or layout revision, and reads the stored rows of no other facade; nested facades across two edges have a fixed histories. The grammar adds optimistic facade inserts and optimistic deletes of shown graph rows; a retired facade must show exactly its visible optimistic rows, and every history ends by settling them and comparing all rows again. A facade commit that throws before committing is generated, and the restore must commit the open sync transaction. Every change pending at a failed root commit, child rows included, stays pending, and the next flush publishes it once. Open: the flush's edge and bucket map copy is unmeasured bookkeeping; disposal between a flush and its rollback has fixed histories only; held facade commits are generated one hold at a time for up to three flushes, and a release that covered earlier flushes checks rows only; the grammar also cleans up a shown facade from outside, lets optimistic rows share graph ids and later rolls them back, after which those keys are compared by value; a holder's cleanup of a shown facade rejects (code 241) and the facade keeps receiving the graph's writes; retiring a shown bucket without the graph's retractions is generated (`contradict`) and throws code 240; optimistic deletes and updates of shown graph rows are generated, and an update's copy keeps the row's key; a facade that `resolve()` created before activation can stay ready and empty after a failed flush (recorded limit). | | Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. The lifecycle history driver checks law 13 at every command: an attempt that settled without an abort keeps no core abort listener on its signal (RED on d0cde4778 in 36 histories; a mutant that keeps the replay participant's listener fails 33). `parkedReloadDemandCases` drives a demand parked behind a failed first load through a reload whose new run marks ready, commits a snapshot inside `sync()` before or after `markReady()`, or commits it after the first load settled: one `loadSubset` per replay attempt that starts work and one `unloadSubset` per replaced acquisition (RED on d0cde4778 for `ready-then-snapshot`: two loads and one unload, because the restart joined the attempt before the attempt's own setup ran; a mutant without the per-attempt check fails it). Limits: these are four fixed histories, not generated; a second reload and asynchronous failures are outside them. | -| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`, `source-health.required-history`); the `problem values`, reload invariant, request error, crash recovery, failed join load, root request, Effect, pooled view, and include node lanes are in `packages/db/tests/source-health.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials and each slot's frame of failures (maintainer decision: a problem shows its newest failure and lists its frame in `errors`; a failure reason's frame is its sync run's reports until `reportHealthy()`, once per error value, less each failure `reportHealthy(error)` removed, and a reload's new run that reports starts a new one), crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. The additive `source-health.required-history` lanes execute all nine existing reach prefixes with generated suffixes, in join/nested contexts with direct subscribers. Each fixed/random lane independently enforces all 26 reach states; the original 60 random histories retain six shapes and both subscriber choices. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, also after a cleanup and a fresh start: the adapter broke its contract for that Collection, by maintainer decision), `cancel` (a commit with an aborted signal changes nothing), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), `cleanup-reload` (cleanup, a fresh start, and a reload in one tick: the cleaned-up run's reload decides nothing for the new one), `snapshot` (`begin({ snapshot: true })` replaces every row at its commit, at any time, also when the reload fails or the Collection crashes before that commit; maintainer decision: only a snapshot or the adapter's `truncate()` replaces rows, and every other write of a reload's new run applies on the kept rows), `mark-ready` (a reload without a snapshot completes at the new run's `markReady()` and keeps the rows; a run's `markReady()` after it is ready or after its `fail()` does nothing, by maintainer decision), `reload-ready-throws` (a first-ready callback that throws while a reload's new run marks ready inside `sync()`, also when the reload starts a cleaned-up Collection through its preload, is reported uncaught and the Collection stays ready, by maintainer decision), and restart. A second `fail()` in a sync run does nothing. A `problem values` lane checks that `fail()`, `reportError()`, and a crash behave the same for any value, also one that cannot describe itself. The Effect lane checks that a problem a source already shows when the Effect's gate opens reaches `onHealthChange` once, that a healthy gate announces nothing, and that each later change is one call. The `effect healthy gate` lane crosses where the gate opens: at the Effect's start, inside an eager source's, a nested query's, or a join's second source's `status:change`, or when an on-demand load settles. A healthy gate that announced its own tree for the status change that opened it failed the three `status:change` openers; an Effect that skips its first healthy change fails the on-demand opener. The lazy grammar's `reload-in-load` checks that a reload from inside the adapter's `loadSubset` rejects and leaves the query running. A query-db receiving witness checks that a direct insert, update, or delete during a reload applies on the kept rows, because it is not a snapshot, and ends with the same rows as during a refetch in flight. A second query-db receiving witness, `housekeeping during a reload` (`query-db.reload-housekeeping`), checks that Query Collection's retention housekeeping keeps the rows during a reload of an eager persisted Collection: an expired persisted retention entry is removed by its expiry timer or by the new run's startup maintenance while the new run's Query fetch is held, and the rows stay, the reload stays in flight, and it ends with the same rows as a refetch. A fourth kind, `expiry-timer-lagging-clock`, makes the expiry timer fire before `Date.now()` reaches `expiresAt`; the entry must still expire in that run, and a callback that drops the early timer fails every time (this was a CI flake and a production bug). The reach counts only begins of the run after the retired one, and the window is observed by polling until the entry is gone. A housekeeping `begin()` marked `snapshot: true` fails it: it replaces the kept rows with none. A third kind, `release-at-startup`, reaches the retention entry that a subset release writes: during a reload of an on-demand persisted Collection, the driver requests and releases a fresh subset through a change subscription in every microtask turn until the new run's `markReady()`, and in the turns after its startup retention maintenance settles each release writes its entry while the reload is in flight; the rows stay and the reload stays in flight in every turn, and it ends with the same rows as a refetch. A retention write marked `snapshot: true` fails it. Release of a Query's rows also begins a housekeeping transaction, but no legal history begins it in the new run while the reload is in flight: core runs the run's cleanup only after retiring its callbacks, and a release takes that path only without a persistence capability, where Query Collection writes no retention entry, so the new on-demand run calls `markReady()` inside `sync()`; a snapshot there is an equivalent mutant for the lane. The lazy grammar's `deferredStart` dimension starts the child the way a persisted Collection does, after the query asked for data, so every lazy history can begin with a first load that enters the adapter from the deferred start; with `reload-in-first-load` that load calls `reload()`, which must reject (a deferred call that skips the guard fails the generated property, shrunk to the empty history). The lazy join grammar checks which request error the query's node shows after every step: every failure has its own message, and the model shows the newest open failure by the order failures became request errors (maintainer decision). It also compares the node's `errors` with the model's frame after every step: every open request failure once, oldest first, each the very value the adapter failed with; a shared error keeps the place of its first failure, and a failed replay lists its failures in failure order, with the newest on top (maintainer decision; the subscription's own replay error stays its earliest held failure). The window grammar makes the same comparison. A replay's verdict decides every request error of the demand it replayed (maintainer decision: coarse verdict): loads that settle while it is judged decide none themselves, its success clears them all, and its failure replaces them with its failures, every failed load of its current attempt that the query still holds for a key that no later held load loaded. Closed from 30-times runs, each now a named history: a query's reload passes through a nested query that a cascade ended, which is a Collection below it that shows A's error, so that inner query's own reload reloads A and starts it, also when another source's reload rejects and the outer query does not start (`a query reload starts an ended nested query even when another source rejects`; the model had reloaded A past the ended inner query). A synchronous throw from `loadSubset` is a rejection (maintainer decision): the lazy driver's `throw-next` failure holds its demand, so the next demand change or replay requests its key again, and the churn replacement twins (`a churn replacement that throws | rejects leaves its key to the older load after the next demand change`) must agree; the driver sees the abort that releases a thrown load's demand. A replay load the query released does not count for the replay's verdict, even when it loaded its keys before the release (law 9: the replay succeeds only as a replacement for the demand that remains; `a released replay load's success does not count for the replay's verdict`; model). A failed replay request that the query released holds nothing, so a parked demand change that asks for its key again parks it (law 9; `a parked growth asks again for a key whose released replay request failed`; model). The main grammar projects every problem's `errors`(messages, order, and which report each cause is) and rejects a problem whose`message`, `cause`, or `at`is not its newest failure's; it checks that a legal`report`or`healthy`announces one`health:change`(a self-join announced two, one per alias, before the relay was registered once per Collection). The demand model treats a replay's failed request of a parked acquisition, which never loaded, as that demand's own failed first load: it covers nothing, and the next demand change parks a retry that holds the failed replay open (law 10; a 30-times counterexample on b521d29d9 of the model, which let the request cover the key, pinned as`a demand change retries a parked key whose replay request failed`; a production mutant that retires the failed segment without a retry fails it and 12 other lazy histories). The `request error bookkeeping`property checks the shared tracker's listed causes and its change result, including a request with several failures. Its`fail-again`step fails an issuer again with every cause it still lists and one new cause, as a replay's later verdict does: a cause still open keeps the place of its first failure (law 17; RED on d0cde4778, which released the issuer's causes before it counted the new list). The lazy and window grammars make`Date.now()`strictly increasing and check every request error's`at`after each step: a failure the model did not show anew keeps the`at`the node showed for it (RED on d0cde4778 in the named history`a failed replay's error stays when parked demand retires after the child restarts`and in the generated lazy property; reach`replay verdict lists an open failure again`, entered by a prefix in which a parked demand joins a failed replay; a mutant that clears the verdict's request errors before it reports its failures fails the named history). Both grammars also check that no `health:change`of a ready query repeats the tree before it. Open (R18, maintainer decision pending on code weight): a replay's verdict, and a lazy load or demand change that decides several keys, announce once per request error they change, so a listener sees intermediate trees (a verdict that replaces key errors with its failures first shows them cleared); a check that every announcement shows a request error frame the model held fails on d0cde4778 (for example`a failed replay's verdict replaces an earlier open failure`, step 5) and passes with a lifecycle `changeRequestErrors(change)`batch that costs about +57 B gzip, so it is not committed. Limits: these announcement checks skip trees whose query is not ready, because a source crash makes the query announce the same crashed tree twice in one step (seen on d0cde4778 at the lazy grammar's`crash`step); the main grammar owns crash announcements and does not count them. Not fixed (R3, R5): during a reload, before it completes, the new run's`reportHealthy()`clears the retired run's failure reason, and a query's own reload drops the retired run's request errors at the new run's first request error; the main and window models encode that behavior, which is in tension with law 16 ("a reload keeps the node's problems until it completes"), and needs a maintainer decision. Not reached (R8): a retired run's committed but unapplied truncate, from a listener that reenters during`commitPendingTransactions`, can complete the new run's reload. A deterministic work witness (`request-errors-work.test.ts`) bounds one failed 2,000-key segment's report-and-settle work at 20·K visits; the original scan-and-sort tracker used 14,007,001. A lazy named history pins that a demand change which retires a fragmented-churn replacement and another segment, while the replaced segment's replay load has failed, fails the replay for the still-held key (laws 9 and 16); the subscription lifecycle owner's demand-change grammar generates the class. A failed replay does not list the failure of a load the query released before the verdict (law 16; glossary request error: it clears when its demand retires); the named history `a failed replay does not list a load the query released before its verdict`fails on the model that listed it and on a production mutant that keeps a released demand's failure. A cleanup during a reload (ARCHITECTURE law 14; maintainer decision that a reload ends with its run) is a generated step in two grammars. The main grammar's`cleanup-during-reload`runs`cleanup({ dependents: true })`from the Collection's or the dependent query's`health:change`while`reload()`announces`reloading: true`. The lazy grammar's `ending`runs`cleanup()`or`cleanup({ dependents: true })`from those, from the Collection's`loadingSubset:change`, or from a change subscription's `status:change`while the reload retires its demand. In both, the model is an ordinary cleanup: no sync run stays live at the adapter, a reload in flight rejects, the Collection ends`cleaned-up`, and a later fresh start's commit is its diff baseline. Setting the epoch after the reload's announcement, or the truncate after the run's retirement, fails 12 histories; restoring the old flag order, or dropping the epoch return, fails the subscription-status ending. Cleanup from the retired run's adapter cleanup function or from the new run's `sync()`is outside this lane. A cleanup of a Collection that a cleanup during a reload left`cleaned-up`has no run to wait for and settles at once; the driver holds an adapter cleanup only while a run is live (a 30-times counterexample of the driver). Focused witnesses check that a reload from inside a`loadSubset`call the deferred start runs rejects, that a cleanup run from a subscription's status listener while a reload retires the run ends that reload with its run (no sync run starts and no replacement stays armed), and that a lazy join load that fails after readiness, under an ordered window that filters on it, publishes the output it held. The main grammar also compares the query's row count at the`status:change`that makes it ready with the count the step leaves (law 9): A's truncate commit that marks A ready while the nested shape's outer query waits for its inner query reaches it, in the first load and in a reload of a failed first load; a completion check that ignores the graph's pending work fails it. Limits: row count only, not identity. After every step the check compares status, rows, the projected tree, node identity,`status:change`/`health:change`order, and`findProblems()`. Named histories pin a reload's new-run `failureReason`surviving its completion. Focused lanes cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled reload commit changes nothing and the reload keeps the rows; a diffing adapter sees an empty baseline inside a reload's snapshot), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios`sync-errors-after-readiness`and`crashed-status`judge the hooks;`sync-errors-after-readiness`reads a two-failure`failureReason.errors`and a one-failure`error.errors`through every framework driver. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle lane. Adapter receivers for reload of a ready Collection (maintainer decision F19: the reload keeps the rows until it completes; only a`begin({ snapshot: true })`transaction or the new run's own`truncate()`replaces them; every other write applies on them): Electric's integration tests`reload of a ready Collection`(a resumed stream with no changes, or with an update, a delete, and an insert, applies them on the kept rows and completes at up-to-date; a stream that cannot resume replaces the rows with its fresh snapshot; a must-refetch keeps the rows through its reset-marker commit and replaces them at its snapshot) and`an on-demand reload keeps its rows and ends with the source's rows`(resumed or not: the replayed subset's rows apply on the kept rows, and the adapter writes an update for a kept key, since core no longer rewrites a kept-key insert and sweeps nothing; the adapter owns removing rows deleted while no stream listened, [#2269](https://github.com/TanStack/db/issues/2269)), and`a progressive reload that cannot resume keeps its rows through a subset load`(no kept row vanishes before the atomic swap, the kept key takes the source's value, the live query has no error; it failed on the parent core with DuplicateKeySyncError); query-db`a reload of a ready eager | on-demand Collection keeps its rows and ends as a refetch does`; PowerSync `eager mode: a reload of a ready Collection keeps its rows and ends with the table`(three batches in one snapshot transaction); RxDB`a reload of a ready Collection keeps its rows and ends with the documents`(a first run that missed changes; two fetch batches); and TrailBase's lifecycle lane`a reload of a ready eager Collection`(10 histories; see its ORACLE.md). On the parent core, Electric's resumed and must-refetch cases, query-db on-demand, PowerSync, and TrailBase's two-page cases fail; RxDB's passes and guards the port. Each of these mutants fails them: a resumed Electric run whose first commit replaces rows; a fresh Electric, PowerSync, or RxDB load without a snapshot; and a query-db housekeeping or direct write marked`snapshot: true`. A reload keeps every row its new run does not rewrite, and core deletes no kept row on its own (maintainer decision; removing a row the backend deleted is the adapter's job, [#2269](https://github.com/TanStack/db/issues/2269)); the adapters' own on-demand reload receivers belong to their packages. Adapter receivers for an on-demand reload (maintainer decision: no core sweep; a held key is written as `update`; issue #2269): Electric `an on-demand reload keeps its rows and ends with the source's rows`(resumed: deleted rows go through the log; cannot resume: a row deleted while no stream listened stays), query-db`a reload of a ready on-demand Collection keeps its rows and ends as a refetch does`(row ownership removes the deleted row), PowerSync`an unchanged row survives an on-demand reload`(the adapter's own tracking removes the deleted row), and the TrailBase lane`an unchanged row survives an on-demand reload`(a row deleted with no event stays). Without Electric's`syncedKeys`seed and its update-for-held-key write, its non-resumed and progressive witnesses fail. Subset snapshot rows go through the same`syncedKeys`check as stream rows. PowerSync`eager mode: a reload whose later batch fails keeps the rows and the next reload ends with the table`checks core state: no open transaction remains and the accepted rows equal the kept rows; a snapshot begun before the batches fails it. Abort receivers (an abort ends the load): Electric, PowerSync, TrailBase, and query-db`an aborted load writes no rows and its late result changes nothing`; each fails under a mutant that ignores the signal at that adapter's stop point. Limits: an Electric abort while a request is in flight still lets the SDK publish its rows; query-db applies a late result after an abort without `unloadSubset`(a replay replacing the load within a run), whose data is current, so it costs a write, not correctness; RxDB has no on-demand path. Electric's`a ready after the stream gave up changes nothing`witnesses that`markReady()`after the run's`fail()`is a no-op (F6): the SDK gives up while a ready run's up-to-date commit waits for a wrapping sync's acceptance, and the acceptance arrives afterwards; there is no unhandled rejection and no status or health change. It fails on the parent core with an unhandled`CollectionStateError`. Limits: the progressive and on-demand Electric witnesses are fixed examples, not generated histories. A joined-filter lane checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure lane checks that. An on-demand reload lane crosses the new run's first commit (an empty `markReady()`or a row commit inside`sync()`, or `markReady()`after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows the kept rows with the new run's own commit on top (no snapshot came). After the loads settle, it checks the new run's rows. A late-first-load lane enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's`reload()`. A query that follows its source into `error`after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls`fail()`before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction:`write`and`commit`steps span other steps, the driver writes like a diffing adapter, a`write`can omit the`tag`field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left).`cleanup-hold`and`cleanup-settle`steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property,`source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()`until a`child-ready`step, so the child can be`loading`or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second`reload()`returns the first one's promise; and the child adapter's own`truncate()`(a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key, also one whose load threw, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, and source reloads during the first load: the first load waits for the whole replay, and a replay fails only for a key the query still needs when it is judged. The child adapter honours aborts; an adapter that ignores them is outside the contract (maintainer decision: an abort ends the load). In the on-demand lane, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted is over and its consumers wait for the replay, as after a truncate; a named history checks that the retired run's load decides nothing when its abort rejects it. A named history pins that a truncate after a reload's failed replay replays the demand again. The on-demand window grammar adds`settle-aborted-fail`and`settle-aborted-ok`, which settle only the loads the query aborted, with declared reach for an aborted load that settles beside a pending load after a request error. A source reload that aborts the app's retry after a request error leaves that error to the replay's verdict: the retry's late rejection or success decides nothing, and a failed replay's verdict replaces the retry's failure. On 2bc79fc7c the coarse verdict's window histories fail, and a mutant whose replay failure reports no error fails 37 tests. Window moves never abort a root load outside a replay, so a root load's late success after an abort outside a replay is unreached (a mutant that lets it clear the root error survives); page-distinct root failures are not modelled: the grammar keeps one logical window demand, as law 16's "later load of that Collection" wording allows. The lazy grammar's `remote-delete`step deletes a child at the provider with no stream event; a child Collection rows check after every step pins that a reload keeps that row (maintainer decision: no reload sweep; RED on 2bc79fc7c, which swept it). The main grammar's blind`insert`write pins that an insert of a key the baseline holds throws, during a reload as at any time (maintainer decision; RED on 2bc79fc7c, which turned a reload's insert of a row the retired run left into an update; a mutant that lets a reload's insert of a held key apply fails 12 named histories); the lazy grammar's diffing adapter writes an`update`for a kept child. Limits: the window grammar has no provider deletion. Every truncate replays the demand again, also one the reload's new run sends while the reload's own replay is open or after it settled (F8: a latch that skipped the reload's own truncate let a replacement delete replayed rows with nothing re-requesting them); a reload with no held acquisition keeps the rows and replays nothing. The window grammar's`reload-query`reaches the query's own reload while its node shows a request error: the query keeps its rows, its node's error, and`reloading`until its new run's window load settles, keeps the window it showed (a window move waiting for a replay ends with the retired run), and then shows only the new run's failure, if any (F4). During the query's own reload the window grammar also issues window moves and`restart`. A move of the new run whose load fails keeps the reload, its rows, `reloading`, and its promise until a later move's load succeeds, and a failure the new run reports shows on the node at once (maintainer decision). `restart`(cleanup and preload of the query) discards the window and any own reload in flight, so the new lifetime loads the initial window. Its limits: no source reload, report, or aborted settle during the own reload; the reload starts only when the source shows no problem; and a restart ends the request-error announcement watch for the rest of the history. Named histories also pin that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay is decided by the replay's verdict, that a reload during the first load fails it only for a demand still held when the replay is judged, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses pin that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in`error`or`crashed`ends, and that a pooled view keeps node identity when its source alias is`root`. Later witnesses pin that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error`status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. The query-db lane`failureReason follows the still-failing Queries` (`query-db.failing-queries`) enumerates every history of four fetches over two subsets' Queries, directly and behind the persisted wrapper (a receiving witness: the wrapper must relay `reportHealthy(error)`'s value). Its model keeps each failing Query's latest error value and the listed values, one per value, oldest first (review: one entry per failing Query, and a value stays listed while any failing Query holds it); fetches succeed, fail with a fresh error, or fail with one shared value; after every fetch it compares the listed causes by identity and order, the shown `message`and`cause`, each kept failure's `at`, and health tree identity (ARCHITECTURE law 17): a fetch that leaves the frame as it was keeps the node and its `failureReason`object and announces no`health:change`. Declared reach includes a Query that fails again while listed and one that recovers with two failures listed while another fails. RED on the parent: a recovered Query's failures stayed listed. The main grammar's `report`and`healthy` steps can name a listed failure's error value or an unlisted value with the same message; a report of a listed value and a healthy call for an unlisted one leave the frame as it was and must announce nothing (maintainer decision), and a healthy call for a listed value removes only that failure. Limits: replacing a Query's failure announces two health:change events (add, then remove). The [snapshot oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts)'s health lane (`live-query-observer.snapshot-health`) generates `report`, `healthy`, `write`, `subscribe`, `unsubscribe`, and `read`over one ready source and checks that every`getSnapshot()`shows the current`failureReason`, `hasErrors`, and `isSyncError`, on detached reads, attached reads, and across the attach cut, and that the tree keeps its identity while unchanged; after a round trip between reads that ends on the previous read's content, either identity is legal (glossary, health tree). Each read also compares the `failureReason`'s `errors`(messages in order, each cause by identity) with the model's frame, and a declared reach state reads a frame of several failures. Limits: one source,`failureReason`only, no hydration seed, no framework. The shared conformance cells`crashed-status`and`crashed-status-during-first-load-{collection,query}`read each driver's status flags without reading`data`, and require Solid's resource path to show `crashed`, not `error`, when preload rejects for a crashed Collection. By maintainer decision, an error boundary receives an Error for a failed first load or a crash: the cause itself when it is an Error, otherwise an Error with the problem's message whose `cause`is the reported value; React's`useLiveSuspenseQuery`and Solid's resource witnesses cover a failed first load and a crash, each with an Error and a non-Error cause: a failure inside sync and after mount, a sync function that throws, and a later crash (after readiness for React, during the first load for Solid). A crash path that throws the raw cause fails the non-Error crash cells in both. The replay refinement oracle's`settle`command can keep holding loads that start during the step, so a held retry keeps a failed join load's request error visible. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a`reload()`that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories pin the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay, also an in-flight one when the child restarts (the CI counterexample seed -838690579: production replaced the in-flight replay and lost its own failed load;`a reload after the source stopped replays its demand and fails with every load`); a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted does not clear a request error; a held replacement that loaded decides the demands it replaces; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady`option does nothing on a ready Collection, also after`fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()`is outside the grammars; a window move with no request is served from loaded rows. A child whose first load failed parks new demand without a request; by maintainer decision the lazy model predicts the parked acquisition from law 10 (growth or churn), and a declared reach state and prefix enter that region. Named histories pin that a replay re-requests parked demand and then publishes, that a released parked demand parks again, that a replay's failed request keeps its acquisition's coverage, and that a later change drops a parked replacement that no longer matches. ARCHITECTURE law 16 states the rule. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's`cleanup({ dependents: true })`and restart, and then subscribes again stays`cleaned-up`, while a live-query Collection restarts; the pooled cleanup-admission lane crosses observed and unobserved views but never restarts their source; a follower tab does not see a provider failure on the leader outside a pending request. | +| Collection status and health tree | `packages/db/tests/source-health-oracle.test.ts` (`source-health.history`, `source-health.required-history`); the `problem values`, reload invariant, request error, crash recovery, failed join load, root request, Effect, pooled view, and include node grammars are in `packages/db/tests/source-health.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | An independent per-Collection model of the first-load gate, `failureReason` and stopped `error` slots with serials and each slot's frame of failures (maintainer decision: a problem shows its newest failure and lists its frame in `errors`; a failure reason's frame is its sync run's reports until `reportHealthy()`, once per error value, less each failure `reportHealthy(error)` removed, and a reload's new run that reports starts a new one), crashes, and reloads derives each live query's status and expected health tree for direct, join, self-join, nested, include, and pooled-nested shapes. The additive `source-health.required-history` grammars execute all nine existing reach prefixes with generated suffixes, in join/nested contexts with direct subscribers. Each fixed/random campaign independently enforces all 26 reach states; the original 60 random histories retain six shapes and both subscriber choices. Generated histories cross `ready`, `fail`, `report`, `healthy`, `crash`, `reload`, `reload-cleanup-fails` (a retired cleanup that rejects crashes the Collection), `reload-cleanup-held` and `retired-cleanup-rejects` (a retired run's cleanup that rejects later still crashes the Collection, also after a cleanup and a fresh start: the adapter broke its contract for that Collection, by maintainer decision), `cancel` (a commit with an aborted signal changes nothing), `reload-query` (a query's `reload()` reaches every Collection below it that shows an `error`, through nested queries; a crashed query rejects without touching its sources), `cleanup-reload` (cleanup, a fresh start, and a reload in one tick: the cleaned-up run's reload decides nothing for the new one), `snapshot` (`begin({ snapshot: true })` replaces every row at its commit, at any time, also when the reload fails or the Collection crashes before that commit; maintainer decision: only a snapshot or the adapter's `truncate()` replaces rows, and every other write of a reload's new run applies on the kept rows), `mark-ready` (a reload without a snapshot completes at the new run's `markReady()` and keeps the rows; a run's `markReady()` after it is ready or after its `fail()` does nothing, by maintainer decision), `reload-ready-throws` (a first-ready callback that throws while a reload's new run marks ready inside `sync()`, also when the reload starts a cleaned-up Collection through its preload, is reported uncaught and the Collection stays ready, by maintainer decision), and restart. A second `fail()` in a sync run does nothing. A `problem values` grammar checks that `fail()`, `reportError()`, and a crash behave the same for any value, also one that cannot describe itself. The Effect grammar checks that a problem a source already shows when the Effect's gate opens reaches `onHealthChange` once, that a healthy gate announces nothing, and that each later change is one call. The `effect healthy gate` grammar crosses where the gate opens: at the Effect's start, inside an eager source's, a nested query's, or a join's second source's `status:change`, or when an on-demand load settles. A healthy gate that announced its own tree for the status change that opened it failed the three `status:change` openers; an Effect that skips its first healthy change fails the on-demand opener. The lazy grammar's `reload-in-load` checks that a reload from inside the adapter's `loadSubset` rejects and leaves the query running. A query-db receiving witness checks that a direct insert, update, or delete during a reload applies on the kept rows, because it is not a snapshot, and ends with the same rows as during a refetch in flight. A second query-db receiving witness, `housekeeping during a reload` (`query-db.reload-housekeeping`), checks that Query Collection's retention housekeeping keeps the rows during a reload of an eager persisted Collection: an expired persisted retention entry is removed by its expiry timer or by the new run's startup maintenance while the new run's Query fetch is held, and the rows stay, the reload stays in flight, and it ends with the same rows as a refetch. A fourth kind, `expiry-timer-lagging-clock`, makes the expiry timer fire before `Date.now()` reaches `expiresAt`; the entry must still expire in that run, and a callback that drops the early timer fails every time (this was a CI flake and a production bug). The reach counts only begins of the run after the retired one, and the window is observed by polling until the entry is gone. A housekeeping `begin()` marked `snapshot: true` fails it: it replaces the kept rows with none. A third kind, `release-at-startup`, reaches the retention entry that a subset release writes: during a reload of an on-demand persisted Collection, the driver requests and releases a fresh subset through a change subscription in every microtask turn until the new run's `markReady()`, and in the turns after its startup retention maintenance settles each release writes its entry while the reload is in flight; the rows stay and the reload stays in flight in every turn, and it ends with the same rows as a refetch. A retention write marked `snapshot: true` fails it. Release of a Query's rows also begins a housekeeping transaction, but no legal history begins it in the new run while the reload is in flight: core runs the run's cleanup only after retiring its callbacks, and a release takes that path only without a persistence capability, where Query Collection writes no retention entry, so the new on-demand run calls `markReady()` inside `sync()`; a snapshot there is an equivalent mutant for the grammar. The lazy grammar's `deferredStart` dimension starts the child the way a persisted Collection does, after the query asked for data, so every lazy history can begin with a first load that enters the adapter from the deferred start; with `reload-in-first-load` that load calls `reload()`, which must reject (a deferred call that skips the guard fails the generated property, shrunk to the empty history). The lazy join grammar checks which request error the query's node shows after every step: every failure has its own message, and the model shows the newest open failure by the order failures became request errors (maintainer decision). It also compares the node's `errors` with the model's frame after every step: every open request failure once, oldest first, each the very value the adapter failed with; a shared error keeps the place of its first failure, and a failed replay lists its failures in failure order, with the newest on top (maintainer decision; the subscription's own replay error stays its earliest held failure). The window grammar makes the same comparison. A replay's verdict decides every request error of the demand it replayed (maintainer decision: coarse verdict): loads that settle while it is judged decide none themselves, its success clears them all, and its failure replaces them with its failures, every failed load of its current attempt that the query still holds for a key that no later held load loaded. Closed from 30-times runs, each now a named history: a query's reload passes through a nested query that a cascade ended, which is a Collection below it that shows A's error, so that inner query's own reload reloads A and starts it, also when another source's reload rejects and the outer query does not start (`a query reload starts an ended nested query even when another source rejects`; the model had reloaded A past the ended inner query). A synchronous throw from `loadSubset` is a rejection (maintainer decision): the lazy driver's `throw-next` failure holds its demand, so the next demand change or replay requests its key again, and the churn replacement twins (`a churn replacement that throws | rejects leaves its key to the older load after the next demand change`) must agree; the driver sees the abort that releases a thrown load's demand. A replay load the query released does not count for the replay's verdict, even when it loaded its keys before the release (law 9: the replay succeeds only as a replacement for the demand that remains; `a released replay load's success does not count for the replay's verdict`; model). A failed replay request that the query released holds nothing, so a parked demand change that asks for its key again parks it (law 9; `a parked growth asks again for a key whose released replay request failed`; model). The main grammar projects every problem's `errors`(messages, order, and which report each cause is) and rejects a problem whose`message`, `cause`, or `at`is not its newest failure's; it checks that a legal`report`or`healthy`announces one`health:change`(a self-join announced two, one per alias, before the relay was registered once per Collection). The demand model treats a replay's failed request of a parked acquisition, which never loaded, as that demand's own failed first load: it covers nothing, and the next demand change parks a retry that holds the failed replay open (law 10; a 30-times counterexample on b521d29d9 of the model, which let the request cover the key, fixed as`a demand change retries a parked key whose replay request failed`; a production mutant that retires the failed segment without a retry fails it and 12 other lazy histories). The `request error bookkeeping`property checks the shared tracker's listed causes and its change result, including a request with several failures. Its`fail-again`step fails an issuer again with every cause it still lists and one new cause, as a replay's later verdict does: a cause still open keeps the place of its first failure (law 17; RED on d0cde4778, which released the issuer's causes before it counted the new list). The lazy and window grammars make`Date.now()`strictly increasing and check every request error's`at`after each step: a failure the model did not show anew keeps the`at`the node showed for it (RED on d0cde4778 in the named history`a failed replay's error stays when parked demand retires after the child restarts`and in the generated lazy property; reach`replay verdict lists an open failure again`, entered by a prefix in which a parked demand joins a failed replay; a mutant that clears the verdict's request errors before it reports its failures fails the named history). Both grammars also check that no `health:change`of a ready query repeats the tree before it. Open (R18, maintainer decision pending on code weight): a replay's verdict, and a lazy load or demand change that decides several keys, announce once per request error they change, so a listener sees intermediate trees (a verdict that replaces key errors with its failures first shows them cleared); a check that every announcement shows a request error frame the model held fails on d0cde4778 (for example`a failed replay's verdict replaces an earlier open failure`, step 5) and passes with a lifecycle `changeRequestErrors(change)`batch that costs about +57 B gzip, so it is not committed. Limits: these announcement checks skip trees whose query is not ready, because a source crash makes the query announce the same crashed tree twice in one step (seen on d0cde4778 at the lazy grammar's`crash`step); the main grammar owns crash announcements and does not count them. Not fixed (R3, R5): during a reload, before it completes, the new run's`reportHealthy()`clears the retired run's failure reason, and a query's own reload drops the retired run's request errors at the new run's first request error; the main and window models encode that behavior, which is in tension with law 16 ("a reload keeps the node's problems until it completes"), and needs a maintainer decision. Not reached (R8): a retired run's committed but unapplied truncate, from a listener that reenters during`commitPendingTransactions`, can complete the new run's reload. A deterministic work witness (`request-errors-work.test.ts`) bounds one failed 2,000-key segment's report-and-settle work at 20·K visits; the original scan-and-sort tracker used 14,007,001. A lazy named history checks that a demand change which retires a fragmented-churn replacement and another segment, while the replaced segment's replay load has failed, fails the replay for the still-held key (laws 9 and 16); the subscription lifecycle owner's demand-change grammar generates the class. A failed replay does not list the failure of a load the query released before the verdict (law 16; glossary request error: it clears when its demand retires); the named history `a failed replay does not list a load the query released before its verdict`fails on the model that listed it and on a production mutant that keeps a released demand's failure. A cleanup during a reload (ARCHITECTURE law 14; maintainer decision that a reload ends with its run) is a generated step in two grammars. The main grammar's`cleanup-during-reload`runs`cleanup({ dependents: true })`from the Collection's or the dependent query's`health:change`while`reload()`announces`reloading: true`. The lazy grammar's `ending`runs`cleanup()`or`cleanup({ dependents: true })`from those, from the Collection's`loadingSubset:change`, or from a change subscription's `status:change`while the reload retires its demand. In both, the model is an ordinary cleanup: no sync run stays live at the adapter, a reload in flight rejects, the Collection ends`cleaned-up`, and a later fresh start's commit is its diff baseline. Setting the epoch after the reload's announcement, or the truncate after the run's retirement, fails 12 histories; restoring the old flag order, or dropping the epoch return, fails the subscription-status ending. Cleanup from the retired run's adapter cleanup function or from the new run's `sync()`is outside this grammar. A cleanup of a Collection that a cleanup during a reload left`cleaned-up`has no run to wait for and settles at once; the driver holds an adapter cleanup only while a run is live (a 30-times counterexample of the driver). Focused witnesses check that a reload from inside a`loadSubset`call the deferred start runs rejects, that a cleanup run from a subscription's status listener while a reload retires the run ends that reload with its run (no sync run starts and no replacement stays armed), and that a lazy join load that fails after readiness, under an ordered window that filters on it, publishes the output it held. The main grammar also compares the query's row count at the`status:change`that makes it ready with the count the step leaves (law 9): A's truncate commit that marks A ready while the nested shape's outer query waits for its inner query reaches it, in the first load and in a reload of a failed first load; a completion check that ignores the graph's pending work fails it. Limits: row count only, not identity. After every step the check compares status, rows, the projected tree, node identity,`status:change`/`health:change`order, and`findProblems()`. Named histories fix a reload's new-run `failureReason`surviving its completion. Focused grammars cover request errors, overlapping root loads (a request error clears only when a load started after the failure succeeds; 8 histories plus a later clearing load), a query's own failed first load not restarting when an unrelated source becomes ready, reload invariant witnesses (the retired run's open sync transaction is dropped; a canceled reload commit changes nothing and the reload keeps the rows; a diffing adapter sees an empty baseline inside a reload's snapshot), and Effect disposal on a failed first load or crash. The shared framework conformance scenarios`sync-errors-after-readiness`and`crashed-status`judge the hooks;`sync-errors-after-readiness`reads a two-failure`failureReason.errors`and a one-failure`error.errors`through every framework driver. Adapter receivers for reload after a failed first load (the new run's own readiness decides it, not Collection status): query-db, RxDB, and PowerSync focused tests, Electric's integration test, and TrailBase's lifecycle grammar. Adapter receivers for reload of a ready Collection (maintainer decision F19: the reload keeps the rows until it completes; only a`begin({ snapshot: true })`transaction or the new run's own`truncate()`replaces them; every other write applies on them): Electric's integration tests`reload of a ready Collection`(a resumed stream with no changes, or with an update, a delete, and an insert, applies them on the kept rows and completes at up-to-date; a stream that cannot resume replaces the rows with its fresh snapshot; a must-refetch keeps the rows through its reset-marker commit and replaces them at its snapshot) and`an on-demand reload keeps its rows and ends with the source's rows`(resumed or not: the replayed subset's rows apply on the kept rows, and the adapter writes an update for a kept key, since core no longer rewrites a kept-key insert and sweeps nothing; the adapter owns removing rows deleted while no stream listened, [#2269](https://github.com/TanStack/db/issues/2269)), and`a progressive reload that cannot resume keeps its rows through a subset load`(no kept row vanishes before the atomic swap, the kept key takes the source's value, the live query has no error; it failed on the parent core with DuplicateKeySyncError); query-db`a reload of a ready eager | on-demand Collection keeps its rows and ends as a refetch does`; PowerSync `eager mode: a reload of a ready Collection keeps its rows and ends with the table`(three batches in one snapshot transaction); RxDB`a reload of a ready Collection keeps its rows and ends with the documents`(a first run that missed changes; two fetch batches); and TrailBase's lifecycle grammar`a reload of a ready eager Collection`(10 histories; see its ORACLE.md). On the parent core, Electric's resumed and must-refetch cases, query-db on-demand, PowerSync, and TrailBase's two-page cases fail; RxDB's passes and guards the port. Each of these mutants fails them: a resumed Electric run whose first commit replaces rows; a fresh Electric, PowerSync, or RxDB load without a snapshot; and a query-db housekeeping or direct write marked`snapshot: true`. A reload keeps every row its new run does not rewrite, and core deletes no kept row on its own (maintainer decision; removing a row the backend deleted is the adapter's job, [#2269](https://github.com/TanStack/db/issues/2269)); the adapters' own on-demand reload receivers belong to their packages. Adapter receivers for an on-demand reload (maintainer decision: no core sweep; a held key is written as `update`; issue #2269): Electric `an on-demand reload keeps its rows and ends with the source's rows`(resumed: deleted rows go through the log; cannot resume: a row deleted while no stream listened stays), query-db`a reload of a ready on-demand Collection keeps its rows and ends as a refetch does`(row ownership removes the deleted row), PowerSync`an unchanged row survives an on-demand reload`(the adapter's own tracking removes the deleted row), and the TrailBase grammar`an unchanged row survives an on-demand reload`(a row deleted with no event stays). Without Electric's`syncedKeys`seed and its update-for-held-key write, its non-resumed and progressive witnesses fail. Subset snapshot rows go through the same`syncedKeys`check as stream rows. PowerSync`eager mode: a reload whose later batch fails keeps the rows and the next reload ends with the table`checks core state: no open transaction remains and the accepted rows equal the kept rows; a snapshot begun before the batches fails it. Abort receivers (an abort ends the load): Electric, PowerSync, TrailBase, and query-db`an aborted load writes no rows and its late result changes nothing`; each fails under a mutant that ignores the signal at that adapter's stop point. Limits: an Electric abort while a request is in flight still lets the SDK publish its rows; query-db applies a late result after an abort without `unloadSubset`(a replay replacing the load within a run), whose data is current, so it costs a write, not correctness; RxDB has no on-demand path. Electric's`a ready after the stream gave up changes nothing`witnesses that`markReady()`after the run's`fail()`is a no-op (F6): the SDK gives up while a ready run's up-to-date commit waits for a wrapping sync's acceptance, and the acceptance arrives afterwards; there is no unhandled rejection and no status or health change. It fails on the parent core with an unhandled`CollectionStateError`. Limits: the progressive and on-demand Electric witnesses are fixed examples, not generated histories. A joined-filter grammar checks that a failed join load lets an ordered window move again. An Effect has no request errors: a failed load after its gate still ends it, and the Effect load-failure grammar checks that. An on-demand reload grammar crosses the new run's first commit (an empty `markReady()`or a row commit inside`sync()`, or `markReady()`after it returns) with an unfiltered and a filtered query. While the new run's loads are in flight, it checks that the query keeps the old run's rows and that a direct read shows the kept rows with the new run's own commit on top (no snapshot came). After the loads settle, it checks the new run's rows. A late-first-load grammar enumerates an on-demand Collection that fails its first load, stops after readiness, or crashes after a direct or joined query is ready, then calls the query's`reload()`. A query that follows its source into `error`after showing data fails it, and so does a query reload that skips a source still in its first load. A focused RxDB test checks that a change-stream error calls`fail()`before and after readiness. The generated grammar also keeps each Collection's rows and the sync run's open transaction:`write`and`commit`steps span other steps, the driver writes like a diffing adapter, a`write`can omit the`tag`field (an update merges into the row, an insert replaces it, also when a failed reload's open transaction applies on the kept rows), and after every step the check compares rows, the adapter's baseline, the query's rows, a direct subscriber with the initial state on each source when the history includes one (its rows equal the Collection's within one Collection lifetime, also while a reload is in flight or after it fails; a source that starts again after cleanup gets a fresh one), and an Effect over A (ended, source errors, listeners left).`cleanup-hold`and`cleanup-settle`steps run every other step while adapter cleanup is pending, including a crash, a new Effect, and the query's reload, which restarts a query a cascade ended. A second generated property,`source-health.on-demand`, drives a root window over an on-demand Collection: window moves, settle-all outcomes, source reloads, and the source's own calls, with requested loads reported from the adapter boundary; it checks the query's status, its request error, the source's node, and the window's rows. A third, `source-health.lazy-demand`, drives an eager parent Collection left-joined to an on-demand child Collection: parents come and go, loads settle ok or failed, the child source reloads, and the source makes its own calls; the driver reports each load's requested keys from the adapter boundary. It checks the query's status, its request error (which clears when a later load of the failed key succeeds or when the query no longer needs that key), and each parent's child. The lazy and window drivers can add a direct subscriber with the initial state on the on-demand source (its rows equal the Collection's at every step; the driver answers its own requests at once), and the lazy driver can add an Effect over the same join (at quiet checkpoints it has the query's rows; a delete that a later insert undoes between checkpoints is not observed). The lazy driver also crosses a child that holds `markReady()`until a`child-ready`step, so the child can be`loading`or fail its first load when it reloads, a reload stays in flight past its new run's first commit, and a second`reload()`returns the first one's promise; and the child adapter's own`truncate()`(a must-refetch in the same sync run, also while a reload is in flight: its replay requests every needed key, also one whose load threw, and a demand change's replacement that applies releases the coverage it replaces), loads that throw, and source reloads during the first load: the first load waits for the whole replay, and a replay fails only for a key the query still needs when it is judged. The child adapter honours aborts; an adapter that ignores them is outside the contract (maintainer decision: an abort ends the load). In the on-demand grammar, a window move after a failed replay waits for the next successful replay, and a later reload aborts it. A reload starts its replay when it retires the sync run, so a load the reload aborted is over and its consumers wait for the replay, as after a truncate; a named history checks that the retired run's load decides nothing when its abort rejects it. A named history checks that a truncate after a reload's failed replay replays the demand again. The on-demand window grammar adds`settle-aborted-fail`and`settle-aborted-ok`, which settle only the loads the query aborted, with declared reach for an aborted load that settles beside a pending load after a request error. A source reload that aborts the app's retry after a request error leaves that error to the replay's verdict: the retry's late rejection or success decides nothing, and a failed replay's verdict replaces the retry's failure. On 2bc79fc7c the coarse verdict's window histories fail, and a mutant whose replay failure reports no error fails 37 tests. Window moves never abort a root load outside a replay, so a root load's late success after an abort outside a replay is unreached (a mutant that lets it clear the root error survives); page-distinct root failures are not modelled: the grammar keeps one logical window demand, as law 16's "later load of that Collection" wording allows. The lazy grammar's `remote-delete`step deletes a child at the provider with no stream event; a child Collection rows check after every step checks that a reload keeps that row (maintainer decision: no reload sweep; RED on 2bc79fc7c, which swept it). The main grammar's blind`insert`write checks that an insert of a key the baseline holds throws, during a reload as at any time (maintainer decision; RED on 2bc79fc7c, which turned a reload's insert of a row the retired run left into an update; a mutant that lets a reload's insert of a held key apply fails 12 named histories); the lazy grammar's diffing adapter writes an`update`for a kept child. Limits: the window grammar has no provider deletion. Every truncate replays the demand again, also one the reload's new run sends while the reload's own replay is open or after it settled (F8: a latch that skipped the reload's own truncate let a replacement delete replayed rows with nothing re-requesting them); a reload with no held acquisition keeps the rows and replays nothing. The window grammar's`reload-query`reaches the query's own reload while its node shows a request error: the query keeps its rows, its node's error, and`reloading`until its new run's window load settles, keeps the window it showed (a window move waiting for a replay ends with the retired run), and then shows only the new run's failure, if any (F4). During the query's own reload the window grammar also issues window moves and`restart`. A move of the new run whose load fails keeps the reload, its rows, `reloading`, and its promise until a later move's load succeeds, and a failure the new run reports shows on the node at once (maintainer decision). `restart`(cleanup and preload of the query) discards the window and any own reload in flight, so the new lifetime loads the initial window. Its limits: no source reload, report, or aborted settle during the own reload; the reload starts only when the source shows no problem; and a restart ends the request-error announcement watch for the rest of the history. Named histories also fix that a later load of a failed key clears its request error while an unrelated load is still pending, that a load failing during a replay is decided by the replay's verdict, that a reload during the first load fails it only for a demand still held when the replay is judged, and that a reload keeps a window move that joined the replay while it aborts a later move that waited. Mutants that restore last round's reload, cleanup, Effect, and replay bugs, and a request error that outlives the failed key's demand, each fail named histories. Witnesses fix that cleanup start announces the problems it drops, that a reload rejects during pending adapter cleanup, that a ready callback that throws still settles a reload, that a failed reload keeps its rows as a diffing adapter's baseline, that a query's own reload settles with its new run (a load that fails again, or a graph that throws), that a source's or the query's reload clears a request error, that an Effect over a source already in`error`or`crashed`ends, and that a pooled view keeps node identity when its source alias is`root`. Later witnesses fix that a reload's open transaction applies on the kept rows after the new run fails, that an Effect over a stopped source leaves no load, subscription, or listener on it, that cleanup start keeps an `error`status's cause, that a source reload's replay keeps a request error that failed after it started, and (query-db) that a late recovery counts when another Query fails meanwhile. The query-db grammar`failureReason follows the still-failing Queries` (`query-db.failing-queries`) enumerates every history of four fetches over two subsets' Queries, directly and behind the persisted wrapper (a receiving witness: the wrapper must relay `reportHealthy(error)`'s value). Its model keeps each failing Query's latest error value and the listed values, one per value, oldest first (review: one entry per failing Query, and a value stays listed while any failing Query holds it); fetches succeed, fail with a fresh error, or fail with one shared value; after every fetch it compares the listed causes by identity and order, the shown `message`and`cause`, each kept failure's `at`, and health tree identity (ARCHITECTURE law 17): a fetch that leaves the frame as it was keeps the node and its `failureReason`object and announces no`health:change`. Declared reach includes a Query that fails again while listed and one that recovers with two failures listed while another fails. RED on the parent: a recovered Query's failures stayed listed. The main grammar's `report`and`healthy` steps can name a listed failure's error value or an unlisted value with the same message; a report of a listed value and a healthy call for an unlisted one leave the frame as it was and must announce nothing (maintainer decision), and a healthy call for a listed value removes only that failure. Limits: replacing a Query's failure announces two health:change events (add, then remove). The [snapshot oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts)'s health grammar (`live-query-observer.snapshot-health`) generates `report`, `healthy`, `write`, `subscribe`, `unsubscribe`, and `read`over one ready source and checks that every`getSnapshot()`shows the current`failureReason`, `hasErrors`, and `isSyncError`, on detached reads, attached reads, and across the attach cut, and that the tree keeps its identity while unchanged; after a round trip between reads that ends on the previous read's content, either identity is legal (glossary, health tree). Each read also compares the `failureReason`'s `errors`(messages in order, each cause by identity) with the model's frame, and a declared reach state reads a frame of several failures. Limits: one source,`failureReason`only, no hydration seed, no framework. The shared conformance cells`crashed-status`and`crashed-status-during-first-load-{collection,query}`read each driver's status flags without reading`data`, and require Solid's resource path to show `crashed`, not `error`, when preload rejects for a crashed Collection. By maintainer decision, an error boundary receives an Error for a failed first load or a crash: the cause itself when it is an Error, otherwise an Error with the problem's message whose `cause`is the reported value; React's`useLiveSuspenseQuery`and Solid's resource witnesses cover a failed first load and a crash, each with an Error and a non-Error cause: a failure inside sync and after mount, a sync function that throws, and a later crash (after readiness for React, during the first load for Solid). A crash path that throws the raw cause fails the non-Error crash cells in both. The replay refinement oracle's`settle`command can keep holding loads that start during the step, so a held retry keeps a failed join load's request error visible. A crash-recovery witness checks that nothing recovers a crash in place and that a cascade cleanup gives a fresh start with only the new run's rows; a`reload()`that recovers a crash, a cleanup that keeps the crashed run's rows, and a crash that survives cleanup each fail it. The three generated properties share one model: the main grammar's source model drives the on-demand Collection of the window and lazy grammars too (a load that succeeds is one sync transaction, so a reload's first load replaces every row), and one demand model, from ARCHITECTURE laws 9, 10, and 16, judges the window grammar's single window demand and the lazy grammar's per-parent keys. The lazy grammar checks the request error after every step, also while a replay is open or after one failed, and both grammars check a coverage law: once the query is ready, every held key has a load the adapter still serves, its rows, a replay failure awaiting judgment, or a shown request error (an aborted load without a replacement fails it). Named histories fix the cases that closing the earlier open item found: a demand parked behind a child whose first load failed joins the open replay, also an in-flight one when the child restarts (the CI counterexample seed -838690579: production replaced the in-flight replay and lost its own failed load;`a reload after the source stopped replays its demand and fails with every load`); a failed replay stays open while the query holds demand, and its error clears when its failed demand retires after the rest loaded (law 9); a load the truncate aborted does not clear a request error; a held replacement that loaded decides the demands it replaces; and a cascade cancels a query's pending reload. Law-silent, recorded with the maintainer: a truncate's `markReady`option does nothing on a ready Collection, also after`fail()`; a cleanup after a crash that followed cleanup settles at once; a truncate after `fail()`is outside the grammars; a window move with no request is served from loaded rows. A child whose first load failed parks new demand without a request; by maintainer decision the lazy model predicts the parked acquisition from law 10 (growth or churn), and a declared reach state and prefix enter that region. Named histories fix that a replay re-requests parked demand and then publishes, that a released parked demand parks again, that a replay's failed request keeps its acquisition's coverage, and that a later change drops a parked replacement that no longer matches. ARCHITECTURE law 16 states the rule. A query that crashes while it starts, because a Collection it reads is still cleaning up, reads nothing: it announces nothing more and no cascade ends it (maintainer decision). A pooled view that is unsubscribed, outlives its source's`cleanup({ dependents: true })`and restart, and then subscribes again stays`cleaned-up`, while a live-query Collection restarts; the pooled cleanup-admission grammar crosses observed and unobserved views but never restarts their source; a follower tab does not see a provider failure on the leader outside a pending request. | | Paced mutations | Partial | The core virtual-clock owner checks strategy timing, admission, cleanup, and settlement. The React render-cut owner checks committed callback ownership across suspended and committed transitions, descendant layout effects, and StrictMode while preserving mutate identity. Callback affinity to transaction admission, other frameworks, new debounce/throttle admission after cleanup, failed persistence, and broader generated schedules remain open. | | Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | -| Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | +| Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, adversarial keys, aliases, cycles, and Map/Set live iteration are literate. | | Query DB and observer | Complete | Query-scope row ownership, subset identity and cancellation, failure and recovery, and the per-listener eligibility ledger are literate. | | Query DB SSR dehydration | Fixed QueryClient/Seroval boundary | Successful on-demand entries with a compound predicate, a request signal, and an ordered cursor with a custom comparator retain their row and user metadata through Seroval's reconstructed stream payload and Query Core hydration; request options are absent from the stream, while query functions retain the original values. The TanStack Start router and browser Collection resume path still need a host witness. | -| Ordered acquisition | Complete | Exact demand identity, applied settlement, independent pagination recomputation, request work, lifecycle products, replay authority, source-generation readiness, and transaction-refinement abort boundaries are literate. An initial full-source ordered load shares the synchronous readiness cut ([historical review](oracle-reviews/initial-full-source-sync-readiness.md)). The ordered-lifecycle oracle covers an inexpressible-boundary fallback before first publication when every acquisition returns literal `true`. It also checks a later null-boundary fallback after the first joined window publishes, across literal, Promise, and held-receipt responses, including a held receipt after child demand settles ([review](oracle-reviews/issue-2152-initial-ordered-fallback.md)). The ordered loader state oracle also has a generated request-authority lane (`ordered-loader.request-authority`, fixed and random campaigns). It drives the real loader over a fake subscription through settlements in any order, failures, deferred invalidation and report reactions, ordinary passes and window moves, and began with four laws from a TLA+ model (#2191): a successful repair keeps its acquisitions until a covering read succeeds; quiet loading holds nothing older than its last authoritative read; a superseded request's failure is recovered by a full-source read without an explicit retry; and once quiet, an invalidation and a pass issue a read unless a full-source read covers the source. Scenarios also throw from the first release callback, answer a repair synchronously from installed rows, and let a page's own delivery invalidate ordering; named replays keep the traces they found, each of which failed on main (a throwing release that unwound a resumed repair, a synchronous repair waiting behind a superseded request, and a self-invalidating page whose tie was never retired). On main it found a superseded tie that failed after its repair finished and a newer invalidation's reaction was deferred, which left refills blocked; a named replay keeps that trace. A stale prefix that retires the repair's prefix fails the held law, and a stale request held after the repair finished fails the leak law. The lane also crosses unexpressible boundaries, joined work that defers loading, truncate replay that succeeds or stays pending, optimistic departures, source deletes, the bounded backoff retry, window moves that shrink or widen, plans that read only the full source, and reports inside the loader's own first page. A replay owns the requests issued before its truncate, and when the subscription still holds a full-source lease the settled replay covers the source. These dimensions found three more defects, each kept as a named replay: a repair prefix that settled after a replay started its replacement took the replacement's chain tag and was never retired; a replay counted a full-source read as complete after an explicit retry had released it; and a failure from before a truncate dropped the next repair's staged step. Each of the seven defect replays fails on the loader at the branch's merge base with main. A later law, one request at a time, found that a replaced repair chain's prefix still counted as current and issued a tie beside the replacement's prefix; a chain is now superseded by any newer authority start. The failed-lease law also counts a covering replay, which a named replay reaches. Repeated random campaigns then found a repair request that settled after full-source coverage and stayed held; full coverage now retires it. The held law counts a later prefix that a successful replay reacquired while it was in flight. Four histories from TCP's known error classes followed: a request the provider never answers (a lost segment), a release callback that truncates synchronously (a reentrant reset), a window offset other than zero, and an at-most-once release law with no release while a replay owns the demand unless an explicit retry replaces it or a held full-source lease covers it. They found that the backoff retry's release pass kept releasing failed demands after a release started a truncate; every release pass now stops on a reentrant reset. The oracle now observes epoch moves as calls to `invalidateSourceOrdering()`, so it tells a replacement chain from a chain step exactly. The loader's work slot and debt now move only through a transition table that throws `OrderedLoaderInvariantError` on an illegal move or an incoherent work and debt pair; the oracle records any such error as a broken law, so its generated histories fuzz the table. A covering-lease law (a full-source lease whose read succeeded or whose demand a replay reacquired is never released) found that a full-source transport failing after a covering replay turned complete coverage into an own failure; the table then found that a repair request failing after a replay finished its chain did the same. Mutants that drop each new guard fail. A release callback that runs an ordinary pass synchronously, as a graph run does when the release delivers deletes outside a transaction, found that the pass issued a page while another request was settling, and that request then issued its tie; every release pass now blocks refinement until it unwinds. A dispose command and a release callback that disposes the loader check that a disposed loader issues and releases nothing; mutants that drop the loader's own `active` checks in the release pass and in failure handling survive because dispose resets the cursor, so those checks were removed. TLA+ run 3 (`LoaderTable.tla`) models the table with truncate and replay; its safe rules pass with 9,368,122 distinct states, and each of the four guards from this round, removed alone, produces a counterexample. Reverting any one fix fails its replay and a campaign. The generated histories also stack a second truncate on a pending replay and truncate after a failed replay, each with a reach witness. A lease missing from its map during a release pass is now an invariant violation; no history reaches it. A second high review found that a covering replay did not retire a superseded failed lease from before its truncate, because the failed-lease law exempted every replay-owned request. The law now judges a superseded failure that was live (it failed in the incarnation it was issued in) once a covering replay settles after it, and the replay retires those leases. The subscriber now schedules the replay's graph run in a finally. The replay-refinement oracle is the receiving witness: its generated ordered section (`replay.ordered-window-faults`) drives a real on-demand Collection and an ordered live query through rank moves, held and failed loads, truncates, and unloads that throw without a truncate, while a replay starts, and while it settles. Its settled-window law found that a release throwing after a successful ordered load left the window on old rows, because the builder counted the rejected participant as a failed load (also on main); the loader now marks release errors and the builder publishes. It also fails without the subscriber's finally. The section now also crosses tied ranks (its law compares the window's rank sequence and each shown row's current rank), a peer live query on the same source, offset 1, descending order, and explicit window moves; a move that rejects after a failed source recovery leaves the window, and one rejected only by a release failure still moves. A reported-faults law accepts injected faults as rejected loads, thrown releases, rejected moves, or errors the subscription rethrows from a microtask, and rejects any other escaped error. These found that a window move whose load succeeded was recorded as failed when a release threw, so the window stayed private until another move. A throwing release is an adapter bug, so the loader now reports it uncaught from a microtask and changes nothing else; the reported-faults law rejects a move that a release failure rejects, and the request-authority oracle requires each throwing release to be reported uncaught exactly once (a mutant that swallows the error fails). It also crosses a joined marker source: a LEFT join that keeps marked or unmarked rows, and an inner join. The marker source is eager or on-demand, with marker loads that are held, fail, and truncate. This found that an on-demand joined source whose loads finished without delivering a row left the window on old rows, because nothing ran the graph that the pending joined work held; the subscriber now runs it when a joined source becomes ready. A truncate never aborts a window move: the move waits for any replay that holds publication and then applies (ARCHITECTURE.md), so a move that rejects with `AbortError` during a history is an escaped error. (An earlier version of this entry claimed a named replay reached an aborted-move branch; no history reached it, and the branch, which quietly accepted such a rejection, is gone.) Recovery truncates the marker source too, because a failed marker replay keeps the last complete publication until that source replays. A hostile-mutant pass over the loader's guards found guards that no ordered oracle kills. A differential instrument then ran 3,000 generated request-authority histories through the loader and each mutant and compared every request's shape, outcome, and release. Three guards the code makes redundant are deleted: the backoff timer guard, the explicit-move check in the retry callback, and the `requiresFullSource` check on a report. An ignored-report law now kills two of the mutants that changed public requests: a report issues no authoritative request before any request settled (a request in flight still owes an exact result for its own options) or while the window is inside the loader's own first prefix. Named replays reach both, and the random campaigns judge them in quiet histories. A random ordered-work campaign later found that the first-prefix check was unsound: it took the next installed row as the next source row, but a signalling refetch moved the page's end before the tie the loader had read, so that tie's row stood past the page while a row tied with the new end was never sent, and a later window move skipped it. The loader now accepts the row only when its page read it or when it read the tie of the page's current end. When the end moved, it repairs (see the later maintainer decision below); query-db's first-page witness requires bounded reads only, no full-source read, and an unchanged count of active Queries. The optimistic-window cases that allow but do not require a refill still bound any refill that occurs: an ordered read within the refill bound or a predicate read, never the full source (a mutant that made the growth read unbounded fails them). The ordered-work model now sorts `undefined` ranks with `null` ones, as the comparator does, and its histories generate both; nullish ranks tie in its window and in-flight comparisons. An `undefined` end also matches a loader that attempted no tie; a nullish end is never an expressible cursor, so the loader has started or will start a full-source read, which covers the source either way. A guard against that match survived the generated campaigns as an equivalent mutant and was removed. The ordered-work oracle keeps the trace as a named replay that fails on main, and the request-authority oracle has an unproven-report law (a report over a first page that no row it read proves starts a repair or reads the end's tie), whose replay and fixed campaign fail on main; the broad campaigns reach that law in about one history in 10,000, so an unproven-report campaign (`ordered-loader.unproven-report`, fixed and random) settles the first page, settles the read after it, and reports, over random case parameters and a random suffix, and declares its reach (about a third of 300 histories); `main`'s loader fails both runs. Its fake subscription now reads installed rows as the subscription does, applying a tie's equality, a cursor, and a limit; when its reads ignored the limit, the tie was always the last installed row's, so the first-prefix ignore branch was unreachable. The cost law's per-chain bound, three requests, was an artifact of that fake: it is now a prefix plus a tie and a page per installed row, and it also judges an unindexed window that still needs rows, which grows its prefix instead of reading the full source. The report campaign (`ordered-loader.unproven-report`, 1,000 runs; at 600 a random seed could miss the minimum) now starts each history with one of eight prefixes and declares reach for each report premise: a proven first page, a page that read past a window that shrank, a held full-source read, a head insert that moved the page's end, an owed repair, authoritative work in flight, a release of the older tie that truncates or disposes the loader, and a report to a disposed loader. The unproven-report law requires a bounded repair or the end's tie when every installed rank is expressible; a full-source read no longer satisfies it (a mutant that forced full debt after a report passed before). The cost law also rejects a repeated read: within one invalidation, no prefix, tie, or page repeats more often than the ordering epoch moved. Two laws are stronger: an ignored report, over a proven first page or a held full-source read, issues no request at all, where it used to forbid only authoritative ones; and a report while a repair is owed reads no tie or page before the repair. Deleting the moved-end early return in `revalidateLoadedCoverage`, or its past-window term, fails the first law, and deleting the owed-debt term in `loadMovedEndTie` fails the second; a deletion sweep had found all three surviving every oracle. With that reach, the report path's own `!active` checks were deleted: `dispose()` advances the generation that the stale-run check after the release reads, and a disposed loader issues nothing, which the dispose law judges. A head insert can carry a `-Infinity` rank, and two more laws judge every request: the loader never reads the tie of a rank no cursor can express, and the first finite request after a truncate has no cursor. The fake's `dataNeeded` now counts the window rows not at or before the boundary it is given, where it returned a constant. With those, the stale-run check after the older tie's release fails the reset law when a release truncates the loader and the window then grows (a prefix declares that reach), and removing `loadBoundary`'s own unexpressible-value check fails the unexpressible-tie law. The covered-source early return and the staged-continuation term were deleted: coverage that a full-source read completed holds the work slot, whose check then decides the report, and a staged finite continuation cannot coexist with a proven end, while a staged repair step owes a repair. The ignored-report law's full-source branch no longer needs a quiet history, since such coverage owes nothing, and a prefix reaches a paged loader whose repair fell back to a full-source read; removing the work-slot check then fails it. The unexpressible-end term in `loadMovedEndTie` stays: the unindexed-refetch random campaign found that without it an unindexed loader reads the full source through the boundary fallback where its bounded prefix repair would do, and a named replay of that trace fails without the term. Maintainer decision, later: a report whose page end moved now repairs instead of reading only the new end's tie, to cut shipped bytes; `loadMovedEndTie` and its guards are gone, so their mutants above no longer exist. The laws stay and judge the repair path: the ignored-report, owed-repair, unexpressible-tie, and reset laws all still run, and the unproven-report law accepts a repair. The report campaign drops its two prefixes whose release of the older tie truncated or disposed the loader, since a report no longer releases anything, and the query-db replay of an insert into a top-N list now expects the bounded repair's prefix and the new end's tie. Still open there: the `Object.is` term of the moved-end check. It skips work when the loader already read the end's tie, and it decides differently from its fallback only while other work is in flight, which the ignored-report law's quiet premise excludes. The ordered-work oracle owns that branch instead: peers over a refetching provider stay in its grammar for source filters (#2155 excludes only joined filters), and a proven-report law requires that a report which changes nothing the first query's own page and end tie prove, with the model's next row installed and no installed row deleted or reordered, starts no request for it and leaves it owing no `refetch: true` repair. A campaign fixed to that premise must reach it, and a loader that repairs every report fails both its fixed and random runs. A staged end tie that pending joined work defers is read by the pass after that work finishes; a request-authority named replay reaches it, and a loader that drops the staged tie fails it. The unproven-report law judges that later pass, and a truncate, failure, move, or disposal in between voids what the report owed. A history that starts during the initial preload found that a window move deferred behind a marker replay, which the source truncate then discarded, rejected with that replay's `AbortError`, so the older move's window won (also on main). A deferred move now waits for every replay that holds publication and applies unless one failed or cleanup ended the sync run; a named replay fails without the fix. The ordered-work refetch law also required no full-source read after a signalling refetch for an unindexed plan, which the architecture then let refill a short window from the full source; random seeds found it on main. Maintainer decision on this branch: an underfilled unindexed prefix never reads the full source, because that read is unbounded. It grows instead: each growth asks for twice the last prefix, and it stops once the last growth added no rows. A deletion sweep showed that the older condition's other term, a prefix that reads short, follows from that one. The refetch law now forbids a full-source read after a signalling refetch with no unindexed exception. The named replay of a short unindexed window and the fixed unindexed-refetch campaign (`ordered-work.eager-unindexed-refetch`, fixed and random, with reach on windows the prefix fills and windows it leaves short) failed on the full-source fallback and pass with growth; the full-source mutant still fails them. Growth made a new optimistic history possible: an unindexed one-row window behind a joined or anti-joined filter that installs only the prefix it grew to, whose departure leaves the replacement row missing. Sixteen fixed optimistic-window cases (update and delete, both consumers, acknowledge and rollback) cover it, with a refill bound of twice the installed prefix plus the overlaid row. A loader without the stop rule never reaches a fixed point; a loader that stops whenever the prefix reads short leaves those windows empty after settlement, because the pending departure hides a row from the count. The existing unindexed optimistic cases now allow, but do not require, a request behind a joined filter when the replacement row is installed: growth proves rows only up to its prefix, where the full-source fallback had proven every row (a maintainer-approved expectation change). The proven-report random campaign now declares its reach too. One mutant still changes requests without a law: dropping the optimistic-refill flag turns a refill into a refetching prefix. Six more are equivalent in the fake but stay, because the fake cannot reach their premise or they encode a documented rule: the own-first-page check behind a held sync transaction (the fake never holds one), a truncate moving complete coverage back to held, clearing the pending request when a full-source read starts (the fake never answers a full-source read synchronously), staging a joined `more` continuation, the staged-continuation guard before an unindexed prefix, and the drain re-entry guard (a long synchronous chain would otherwise recurse). An include dimension adds a `toArray` child that reads the marker source for each shown row, and an include-children law requires each shown row's markers to equal the model wherever the window law holds; a mutant that never loads newly demanded child keys fails it in an include-only scenario. A newest-move law requires a superseded window move to resolve only after the newest move settles (a superseded move that fails still rejects with its own error), and the model lets the newest move that resolved set the window; it found that a superseded move resolved as soon as its own load settled, so awaiting it showed neither window. The terminal-error law also allows `error` after a load fails while the query is still loading, the documented initial-load failure; a lazy join source is exempt. Limits: includes are one level deep. It also found that a release failure in lazy joined demand during a window move rejected the move, because the subscription reported it as a load error. A window law now lets the newest move win; it found that two moves deferred by a pending source recovery could resume in the opposite order, so the older request replaced the newer window. `setWindow()` now ignores a deferred call that a newer call superseded. A terminal-error law allows a final `error` only after a lazy join demand load, on either side, failed outside a truncate's recovery; named replays reach that branch. A load belongs to a recovery when it asks again for a demand its source held before the truncate (the same request data), or when the graph starts it while the truncate commits. The first model read a flag set only during the commit, but subscriptions start replay loads in a later microtask, so it counted every replay load as new demand and allowed `error` after any failed replay load; a random campaign found the reverse case (#2224): a marker truncate privately moved the window, the include's new demand started outside any replay because releasing its old demand ended the subscription's replay, and its failure ended the query. The demand controller now starts new coverage before it releases retired coverage, a query subscription opens a replay at every truncate even without demand (a replay that starts no recovery work and reports no loading), and a demand failure that fails its subscription's replay keeps the last publication instead of ending the query once the query has first become ready. The section also generates histories that start during the initial preload, with an initial-readiness law (the preload has settled once loading is quiet), marker loads whose aborted requests reject with `AbortError` as the source contract asks, and settles of the newest held load. These found two older defects on main: an aborted lazy demand request that rejected after its truncate replay succeeded ended the query, and so did a superseded segment that the controller released; the controller now treats an aborted request as obsolete demand, which its replay or newer coverage decides. Named replays keep each trace; reverting each production change fails one of them, and a mutant whose replay rebuilds its request data fails the campaigns' replay-load reach witness. The ordered-lifecycle oracle owns the truncate-work witness: a zero-limit window issues no request and reports no loading at a truncate, while a one-row window reacquires. A retention law then judges law 9 at generated checkpoints: a truncate raises a publication barrier, every load its source starts while the barrier is up joins it, a participant's failure fails the replay while its logical demand is held, and while any barrier is up the query shows exactly the rows it showed when the first one rose. It runs after every command and once loading is quiet. A failed-replay campaign (`replay.ordered-failed-replay`, fixed and random) starts each history by holding loads, truncating a source, optionally starting demand, and failing a held load, with a branch that fails an include demand while its marker replay still loads; it declares four reach states with `createOracleReach` (a judged failed replay, a judged held replay, a demand started after the commit that fails while its replay loads, and the include form of that demand). A replay participant now also counts as recovery for the terminal-error law, so a demand that starts after the commit while its replay is open is judged in generated histories, not only by a named replay. A mutant whose failed replay releases the publication gate fails both failed-replay campaigns and the broad campaigns; a mutant that ends the query when such a demand fails fails both failed-replay campaigns; the branch's production files reverted to `main` fail both. A preload-recovery campaign (`replay.ordered-preload-recovery`, fixed and random) starts every history during the initial preload over on-demand markers and declares two more states: lazy demand in a replay that fails before the first ready, and a window move that waits behind a replay a newer truncate discards. A builder whose deferred move a discarded replay rejects, and one that keeps a failure before the first ready non-fatal, each failed only a named replay before; both now fail the fixed campaign, whose seed kills both (each dies in about half of 200-run seeds, so the random campaign kills each about half the time). Building the law found three model errors, each corrected from ARCHITECTURE.md: a failure is scoped to its logical demand, so a retired failed demand and the demand that replaces it publish when the replacement succeeds; a replay completes when its last participant's rows apply, so demand that a deferred window move starts afterwards does not join it; and an ordered tie request (a `where` on rank) is not lazy demand, which the terminal-error law had treated it as, so a failed tie request had excused a terminal error. A query's subscriptions to one collection now share one replay barrier (ARCHITECTURE.md, "Replay participants and failure"): a probe found that in a query that joins and includes the marker source, a marker truncate retired the include's only demand privately, its emptied replay closed while the join's replay still loaded, and include demand the graph then started failed outside every replay and ended the query. A sibling-demand branch of the failed-replay campaign declares that state; the branch's code before the fix fails the named replay and both campaigns at `convergence: terminal error`, and removing either sibling hold (at replay completion, or for an emptied replay) fails all three. The random failed-replay campaign then found the other half: one subscription's replay failed, the other's finished once it settled, and demand that a rank move then started on the finished subscription failed and ended the query, because the subscriber judged the failure by its own subscription. The subscriber now asks whether a replay of the source's collection failed; a named replay (`a demand that fails after the sibling subscription's replay failed`), a `failedSiblingDemandFailures` reach in the sibling branch (which now may move a row and fail the oldest held load), and both campaigns at 400 runs fail at `convergence: terminal error` on the per-subscription rule. A design that judged a failure against any failed replay of the query, across collections, used to survive, because the convergence check runs after an explicit window move, which retries a failed row demand. A strict terminal-error law now requires a failed lazy demand outside every recovery to end the query (ARCHITECTURE.md, error 228), skipping only failures the model cannot place: after a load threw, and, with a peer, a load that joined a replay. Merge with #2257: its review (CF01, [record](oracle-reviews/2026-10-26-pr-2257.md)) judged a new keyed request that a truncate commit starts as terminal, and called non-fatal recovery of that demand a future contract. This PR adopts that contract by maintainer decision. The merged owner keeps CF01's initiating-request receipt check as a second account of every terminal error, at each history cut and at convergence, plus its named replays and the `replay.subscription-demand-origin` and `replay.keyed-request-failures` campaigns, with their two reach-state pairs measured on this owner's model. Both demand origins, new demand during a marker commit and a settled demand that the replay asks for again, must stay nonterminal after the first ready. An always-fatal `failDemand` mutant fails 15 tests, including both ported campaigns; a never-fatal mutant fails the strict law in 8. CF01's duplicate of the #2224 named replay is folded into this owner's existing one. Merge with #2168 (ARCHITECTURE.md law 16): after initial-query readiness no load failure ends a live query, and a failed lazy demand is a request error on the query's node. That supersedes the strict terminal-error law, the error-228 rule, and the always-fatal and never-fatal mutant results above for a query that has been ready. The replay owner now allows a terminal error only after a load failed while the query was still loading, judged at each history cut and at convergence, and it adopts #2168's request-error witness (`requestErrors`); the excusals for thrown and unplaced failures went with the strict law. #2168's design already starts new demand before it releases retired coverage, and it treats a synchronous `loadSubset` throw as a rejection whose owner's release unloads it (the subscription oracles pin that unload), so the adapter-release law now counts a thrown load as acquired. It lacked the shared barrier, the failure hold behind a sibling's failed replay, and the deferred window move's wait, which the merge ports onto it. Without the deferred-move loop, 8 tests fail, #2221 trace 2 among them; without `failReplacement`, the two-failures named replay fails; without the sibling wait, that replay and both failed-replay campaigns fail. A deferred move rejects when a reload ends the sync run of the query or of a source, which the source-health oracle requires. The window-request reset went: a move can no longer throw, and deleting it failed nothing. The replay that a truncate opened without demand went too: under law 16 its demand's failure no longer ends the query, and its named replay passes on #2168's subscription. Review of the merge (2026-10-28): the replay owner gained a request-error account. After initial-query readiness, each failed lazy demand load that is still owed must appear among the query node's open request failures, by its injected error object, at every history cut, and no request error may stay after recovery replays every source (authority: ARCHITECTURE.md law 16, law 17, and the glossary's request error). A failure is met when its demand retires, a later load of the source covers its keys, or a later replay of the source completes; a failure inside a replay owes nothing until every participant settled. A terminal error now needs a load that failed while that same query was loading, so a peer's initial failure no longer excuses `live`. A thrown load joins a replay that no participant joined yet, and the failed-sibling branch now generates a held retry. `requestErrorChecks` reaches the declared minimum of 5 in each failed-replay campaign. Mutants: demand outcomes that never report an error fail 9 tests at the per-cut check; a successful replay that keeps its request errors fails 22 at the stale-error check. Limits and open items: the account shares one replay across a query's subscriptions to a source, so it cannot judge one subscription's request error while another load of that source is held. The reviewer's finding that `failReplacement`'s replay, which has no recorded failure, completes when every demand that joined it settles is closed as a law revision by maintainer decision: the hold ends when every demand load that joins it loads, a later replay of the source succeeds, or the subscription's last demand retires (ARCHITECTURE.md, "Replay participants and failure"). The query retries the failed demand at once and that retry joins the hold, so the hold cannot publish without the failed demand's rows. The replay oracle judges each subscription's request-error verdict by the subscription that a load names, and its failed-replay campaigns declare the premise (a subscription's load succeeds while that subscription's failure is still owed; 30 fixed and 24 random histories at the normal budget). No live-query history violates the request-error or retention law there; a mutant that completes such a hold when any participant settles fails both failed-replay campaigns at the retention law. A subscription-level `it.fails` test that asserted the earlier, stricter wording (no joined demand may end the hold) was removed with the revision. Limit: the oracle drives the hold only through live-query histories, where the retry always joins; a direct `Collection.subscribeChanges` caller that never retries is outside the live-query contract. Which failure a deferred window move rejects with, when its replay has several, is not specified: `main` rejected with the replay's earliest failure, and this branch rejects with `lastSubsetError`; by maintainer decision the move rejects with its replay's earliest failure that still decides it (law 17 decides which count: a retired demand's, a retried request's, and a replaced demand's failures do not). An identity law now judges deferred moves that start before any failure of the replays they wait on; on the parent code both failed-replay campaigns and the named replay `a window move behind a replay with two failed loads` fail with "replay error identity", and the deferred-move loop that rethrows a non-abort replay error passes. The request-error account decides failures per key: a later load of the source that asks for some of a failure's keys decides them, and keys it no longer asks for left the demand. The held-retry branch then found, at three times the run budget, a marker change publishing over a window that the model retained after a failed marker replay (`hold, truncateMarkers, settle ok, settle ok, settle fail newest, mark 3 off`, ascending, offset 1, limit 1, includes over on-demand markers). It was a model defect: the query retries a failed demand at once, and when that retry loads the failed keys the failure no longer counts (ARCHITECTURE.md law 17), so the replay succeeds and publishes. The model now clears a replay failure when a later load of the replay loads its keys. The replay oracle passes at the normal budget and three times at three times it, and production without `failReplacement` still fails its named replay at the retention law. A move whose `setWindow` throws synchronously after the request generation increments is reachable only through invariant errors, which probes for a throwing subscriber, a crashed source, and a throwing `select` did not reach. An aborted request's rejection and the failure of a segment that newer coverage released are not failures. That design now fails both failed-replay campaigns. An independent oracle review then narrowed several judgments. The terminal-error excuse after a synchronous throw applies only to the thrown error itself, ending the query after the step that threw; the throw's own step, and every other failure, are judged strictly (a per-history flag had excused every later failure). A load that fails outside every barrier after a failed demand of its source retired is unplaced, because the failed replay may still hold through the subscription's other demands, which the adapter cannot attribute. Limit: with a peer, the strict law accepts either query ending, since the adapter boundary cannot tell which query's subscription issued a load; attributing each failure to its query's subscription would close that. The window check now also requires every shown row to be eligible and to appear once, so a tie cannot hide a row the join rejects. A synchronous-throw dimension (`markerThrows` arms the next marker load to throw while it starts) found that a load throwing inside an open replay ended the query: the subscription dropped the demand, so its failure did not fail the replay, and the subscriber treated the throw as fatal. The subscription now hands the replay a rejected participant for such a throw and keeps the demand, which a later replay retries; named replays (`a replay load that throws while it starts`, `a demand whose load throws inside its open replay`), a `thrownReplayDemands` reach in a thrown-demand branch, and both failed-replay campaigns (now 600 runs) fail on the parent code, and a mutant that later unloads the never-acquired load fails the adapter-release check. Model corrections, each from the adapter-boundary rule that a thrown load acquires nothing: a thrown load joins a replay only if it still has a participant or a failure; an armed throw expires before the drain; and a thrown demand's retirement is invisible, so the retention law skips the marker barrier from a throw to the end of its episode (a later barrier in the episode inherits the ambiguity, which also corrects the existing release-throw case), and a failure that joins an already failed or ambiguous barrier may end the query. Maintainer decision: a synchronous throw inside an open replay fails that replay as a rejection does, so the subscription replay oracle's `new request throws` case and five `live-query-collection` cases, which expected a thrown new demand to roll back, now expect the rejection outcome. Limits: a design that dropped a thrown demand and let its replay succeed passes wherever only retention could see it; after a history command follows a throw, a terminal error is excused, because the model cannot see whether that command retired the thrown demand and with it its failed replay (a demand that waited on that replay then fails outside it). The named replays judge the throw's own outcome strictly. The failed-replay random campaign at five times its run count then found a retention counterexample with no throw, on the parent code too: a join and an include of one marker source; one subscription's replay fails, the other's finishes, and a row move starts demand on it whose load fails, which the shared barrier kept from ending the query; a marker change then retired the first replay's failed demand, which discarded that replay and published an empty window, because the second failure held nothing. That failure now fails its own subscription's replacement, which holds until a later replay succeeds or the subscription's last demand retires. A named replay of the trace fails without the hold, and a `partialFailureRetirements` reach (a failed demand retires while another failure still holds the barrier) is declared in the failed-replay campaign; the campaigns themselves do not fail without the hold, so the named replay is the witness. A window move whose load throws while it starts counts as a settled, failed move in the model (it used to stay pending, so the newest-move law misjudged the older move), with a `thrownMoves` reach. The broad random campaign at five times its run count then found an older move whose settlement resolved with itself: the newer move threw after it bumped the request generation and before it replaced the latest request, so the older move's settlement returned its own promise, a chaining cycle that escaped as a TypeError. A call now clears the latest request when it bumps the generation, so no older move can wait on itself; a named replay fails without that. The same assignment covers a move deferred behind a replay, which had the same shape (an independent review found it can hang forever when a newer move throws between the replay settling and the deferred move resuming); that microtask gap is outside the grammar, which flushes between commands, so only the shared mechanism, not that path, has a witness. The ordered-work oracle also keeps the trace from #2221 as a named replay: before prefix growth the loader read the full source twice after a signalling refetch past a NaN boundary; with growth it reads none. Limits: the adapter boundary cannot tell which query issued a load, so the retention law and the participant rule skip histories with a peer. The retention law judges barriers that rise after the query first became ready; before that the initial-readiness law governs. It skips a barrier where a release threw while the replay started, which may fail that replay. Core now reports a throwing `unloadSubset` uncaught and fails nothing with it; the subscription lifecycle, replay, Effect disposal, includes-temporal, and subset-error oracles check the uncaught report, once, with the adapter's error. The request-authority oracle now also generates requests that throw while they start, the last history only TLA+ covered, with an error-identity law: a failed request's participant reports that request's own error. It found that a superseded failure whose recovery read threw at its start reported the start error instead (review finding 6). The state oracle compares every current failed request's participant reason with its own originating Error by identity. Release cleanup cannot replace that reason, even when the thrown value's message starts with “release ”. A bounded 16-case checker calibration crosses index availability, widths 1/2, actual throwing release absent/present, and ordinary/full-source plans. It feeds three wrong participant reasons into the same model/production-driver/comparison; the original prefix waiver and an actual-release-only waiver fail at that check. The existing fixed/random 10,000 authority histories declare and enforce current-request-error reach and actual cleanup-fault/current-request-error overlap. Scope ends at the loader participant and separate uncaught-cleanup observations; it adds no provider/framework or intermediate-publication promise. The state owner's shared error-identity calibration and paired generated campaigns enforce these checks. A cost law then checks, after loading is quiet and a bounded repair is available, that one invalidation reads no full source and issues at most one chain per ordering epoch move (in the fake, a prefix, a tie, and a refill); a mutant that always repairs with a full-source read fails it. The replay-refinement section's status law requires a quiet live query and its peer to stay ready when no load failed. Limits: whether a release runs the graph at once or defers it to a transaction belongs to the scheduler, and the oracle generates both forms without checking the scheduler's choice; the fake's rows do not move except for the unexpressible boundary; publication holds stay with other tests; the laws do not judge requests issued before a truncate; a hung authoritative request suspends the leak law for its unfinished chain; a recovery start that throws during a superseded failure is rare in the campaigns, so a named replay owns it. | +| Ordered acquisition | Complete | Exact demand identity, applied settlement, independent pagination recomputation, request work, lifecycle products, replay authority, source-generation readiness, and transaction-refinement abort boundaries are literate. An initial full-source ordered load shares the synchronous readiness cut ([historical review](oracle-reviews/initial-full-source-sync-readiness.md)). The ordered-lifecycle oracle covers an inexpressible-boundary fallback before first publication when every acquisition returns literal `true`. It also checks a later null-boundary fallback after the first joined window publishes, across literal, Promise, and held-receipt responses, including a held receipt after child demand settles ([review](oracle-reviews/issue-2152-initial-ordered-fallback.md)). The ordered loader state oracle also has a generated request-authority grammar (`ordered-loader.request-authority`, fixed and random campaigns). It drives the real loader over a fake subscription through settlements in any order, failures, deferred invalidation and report reactions, ordinary passes and window moves, and began with four laws from a TLA+ model (#2191): a successful repair keeps its acquisitions until a covering read succeeds; quiet loading holds nothing older than its last authoritative read; a superseded request's failure is recovered by a full-source read without an explicit retry; and once quiet, an invalidation and a pass issue a read unless a full-source read covers the source. Scenarios also throw from the first release callback, answer a repair synchronously from installed rows, and let a page's own delivery invalidate ordering; named fixed histories keep the traces they found, each of which failed on main (a throwing release that unwound a resumed repair, a synchronous repair waiting behind a superseded request, and a self-invalidating page whose tie was never retired). On main it found a superseded tie that failed after its repair finished and a newer invalidation's reaction was deferred, which left refills blocked; a named fixed history keeps that trace. A stale prefix that retires the repair's prefix fails the held law, and a stale request held after the repair finished fails the leak law. The grammar also crosses unexpressible boundaries, joined work that defers loading, truncate replay that succeeds or stays pending, optimistic departures, source deletes, the bounded backoff retry, window moves that shrink or widen, plans that read only the full source, and reports inside the loader's own first page. A replay owns the requests issued before its truncate, and when the subscription still holds a full-source lease the settled replay covers the source. These dimensions found three more defects, each kept as a named fixed history: a repair prefix that settled after a replay started its replacement took the replacement's chain tag and was never retired; a replay counted a full-source read as complete after an explicit retry had released it; and a failure from before a truncate dropped the next repair's staged step. Each of the seven defect replays fails on the loader at the branch's merge base with main. A later law, one request at a time, found that a replaced repair chain's prefix still counted as current and issued a tie beside the replacement's prefix; a chain is now superseded by any newer authority start. The failed-lease law also counts a covering replay, which a named fixed history reaches. Repeated random campaigns then found a repair request that settled after full-source coverage and stayed held; full coverage now retires it. The held law counts a later prefix that a successful replay reacquired while it was in flight. Four histories from TCP's known error classes followed: a request the provider never answers (a lost segment), a release callback that truncates synchronously (a reentrant reset), a window offset other than zero, and an at-most-once release law with no release while a replay owns the demand unless an explicit retry replaces it or a held full-source lease covers it. They found that the backoff retry's release pass kept releasing failed demands after a release started a truncate; every release pass now stops on a reentrant reset. The oracle now observes epoch moves as calls to `invalidateSourceOrdering()`, so it tells a replacement chain from a chain step exactly. The loader's work slot and debt now move only through a transition table that throws `OrderedLoaderInvariantError` on an illegal move or an incoherent work and debt pair; the oracle records any such error as a broken law, so its generated histories fuzz the table. A covering-lease law (a full-source lease whose read succeeded or whose demand a replay reacquired is never released) found that a full-source transport failing after a covering replay turned complete coverage into an own failure; the table then found that a repair request failing after a replay finished its chain did the same. Mutants that drop each new guard fail. A release callback that runs an ordinary pass synchronously, as a graph run does when the release delivers deletes outside a transaction, found that the pass issued a page while another request was settling, and that request then issued its tie; every release pass now blocks refinement until it unwinds. A dispose command and a release callback that disposes the loader check that a disposed loader issues and releases nothing; mutants that drop the loader's own `active` checks in the release pass and in failure handling survive because dispose resets the cursor, so those checks were removed. TLA+ run 3 (`LoaderTable.tla`) models the table with truncate and replay; its safe rules pass with 9,368,122 distinct states, and each of the four guards from this round, removed alone, produces a counterexample. Reverting any one fix fails its replay and a campaign. The generated histories also stack a second truncate on a pending replay and truncate after a failed replay, each with reach. A lease missing from its map during a release pass is now an invariant violation; no history reaches it. A second high review found that a covering replay did not retire a superseded failed lease from before its truncate, because the failed-lease law exempted every replay-owned request. The law now judges a superseded failure that was live (it failed in the incarnation it was issued in) once a covering replay settles after it, and the replay retires those leases. The subscriber now schedules the replay's graph run in a finally. The replay-refinement oracle is the receiving witness: its generated ordered section (`replay.ordered-window-faults`) drives a real on-demand Collection and an ordered live query through rank moves, held and failed loads, truncates, and unloads that throw without a truncate, while a replay starts, and while it settles. Its settled-window law found that a release throwing after a successful ordered load left the window on old rows, because the builder counted the rejected participant as a failed load (also on main); the loader now marks release errors and the builder publishes. It also fails without the subscriber's finally. The section now also crosses tied ranks (its law compares the window's rank sequence and each shown row's current rank), a peer live query on the same source, offset 1, descending order, and explicit window moves; a move that rejects after a failed source recovery leaves the window, and one rejected only by a release failure still moves. A reported-faults law accepts injected faults as rejected loads, thrown releases, rejected moves, or errors the subscription rethrows from a microtask, and rejects any other escaped error. These found that a window move whose load succeeded was recorded as failed when a release threw, so the window stayed private until another move. A throwing release is an adapter bug, so the loader now reports it uncaught from a microtask and changes nothing else; the reported-faults law rejects a move that a release failure rejects, and the request-authority oracle requires each throwing release to be reported uncaught exactly once (a mutant that swallows the error fails). It also crosses a joined marker source: a LEFT join that keeps marked or unmarked rows, and an inner join. The marker source is eager or on-demand, with marker loads that are held, fail, and truncate. This found that an on-demand joined source whose loads finished without delivering a row left the window on old rows, because nothing ran the graph that the pending joined work held; the subscriber now runs it when a joined source becomes ready. A truncate never aborts a window move: the move waits for any replay that holds publication and then applies (ARCHITECTURE.md), so a move that rejects with `AbortError` during a history is an escaped error. (An earlier version of this entry claimed a named fixed history reached an aborted-move branch; no history reached it, and the branch, which quietly accepted such a rejection, is gone.) Recovery truncates the marker source too, because a failed marker replay keeps the last complete publication until that source replays. A mutant-mutant pass over the loader's guards found guards that no ordered oracle kills. A differential instrument then ran 3,000 generated request-authority histories through the loader and each mutant and compared every request's shape, outcome, and release. Three guards the code makes redundant are deleted: the backoff timer guard, the explicit-move check in the retry callback, and the `requiresFullSource` check on a report. An ignored-report law now kills two of the mutants that changed public requests: a report issues no authoritative request before any request settled (a request in flight still owes an exact result for its own options) or while the window is inside the loader's own first prefix. Named replays reach both, and the random campaigns judge them in quiet histories. A random ordered-work campaign later found that the first-prefix check was unsound: it took the next installed row as the next source row, but a signalling refetch moved the page's end before the tie the loader had read, so that tie's row stood past the page while a row tied with the new end was never sent, and a later window move skipped it. The loader now accepts the row only when its page read it or when it read the tie of the page's current end. When the end moved, it repairs (see the later maintainer decision below); query-db's first-page witness requires bounded reads only, no full-source read, and an unchanged count of active Queries. The optimistic-window cases that allow but do not require a refill still bound any refill that occurs: an ordered read within the refill bound or a predicate read, never the full source (a mutant that made the growth read unbounded fails them). The ordered-work model now sorts `undefined` ranks with `null` ones, as the comparator does, and its histories generate both; nullish ranks tie in its window and in-flight comparisons. An `undefined` end also matches a loader that attempted no tie; a nullish end is never an expressible cursor, so the loader has started or will start a full-source read, which covers the source either way. A guard against that match survived the generated campaigns as an equivalent mutant and was removed. The ordered-work oracle keeps the trace as a named fixed history that fails on main, and the request-authority oracle has an unproven-report law (a report over a first page that no row it read proves starts a repair or reads the end's tie), whose replay and fixed campaign fail on main; the broad campaigns reach that law in about one history in 10,000, so an unproven-report campaign (`ordered-loader.unproven-report`, fixed and random) settles the first page, settles the read after it, and reports, over random case parameters and a random suffix, and declares its reach (about a third of 300 histories); `main`'s loader fails both runs. Its fake subscription now reads installed rows as the subscription does, applying a tie's equality, a cursor, and a limit; when its reads ignored the limit, the tie was always the last installed row's, so the first-prefix ignore branch was unreachable. The cost law's per-chain bound, three requests, was an artifact of that fake: it is now a prefix plus a tie and a page per installed row, and it also judges an unindexed window that still needs rows, which grows its prefix instead of reading the full source. The report campaign (`ordered-loader.unproven-report`, 1,000 runs; at 600 a random seed could miss the minimum) now starts each history with one of eight prefixes and declares reach for each report premise: a proven first page, a page that read past a window that shrank, a held full-source read, a head insert that moved the page's end, an owed repair, authoritative work in flight, a release of the older tie that truncates or disposes the loader, and a report to a disposed loader. The unproven-report law requires a bounded repair or the end's tie when every installed rank is expressible; a full-source read no longer satisfies it (a mutant that forced full debt after a report passed before). The cost law also rejects a repeated read: within one invalidation, no prefix, tie, or page repeats more often than the ordering epoch moved. Two laws are stronger: an ignored report, over a proven first page or a held full-source read, issues no request at all, where it used to forbid only authoritative ones; and a report while a repair is owed reads no tie or page before the repair. Deleting the moved-end early return in `revalidateLoadedCoverage`, or its past-window term, fails the first law, and deleting the owed-debt term in `loadMovedEndTie` fails the second; a deletion sweep had found all three surviving every oracle. With that reach, the report path's own `!active` checks were deleted: `dispose()` advances the generation that the stale-run check after the release reads, and a disposed loader issues nothing, which the dispose law judges. A head insert can carry a `-Infinity` rank, and two more laws judge every request: the loader never reads the tie of a rank no cursor can express, and the first finite request after a truncate has no cursor. The fake's `dataNeeded` now counts the window rows not at or before the boundary it is given, where it returned a constant. With those, the stale-run check after the older tie's release fails the reset law when a release truncates the loader and the window then grows (a prefix declares that reach), and removing `loadBoundary`'s own unexpressible-value check fails the unexpressible-tie law. The covered-source early return and the staged-continuation term were deleted: coverage that a full-source read completed holds the work slot, whose check then decides the report, and a staged finite continuation cannot coexist with a proven end, while a staged repair step owes a repair. The ignored-report law's full-source branch no longer needs a quiet history, since such coverage owes nothing, and a prefix reaches a paged loader whose repair fell back to a full-source read; removing the work-slot check then fails it. The unexpressible-end term in `loadMovedEndTie` stays: the unindexed-refetch random campaign found that without it an unindexed loader reads the full source through the boundary fallback where its bounded prefix repair would do, and a named fixed history of that trace fails without the term. Maintainer decision, later: a report whose page end moved now repairs instead of reading only the new end's tie, to cut shipped bytes; `loadMovedEndTie` and its guards are gone, so their mutants above no longer exist. The laws stay and judge the repair path: the ignored-report, owed-repair, unexpressible-tie, and reset laws all still run, and the unproven-report law accepts a repair. The report campaign drops its two prefixes whose release of the older tie truncated or disposed the loader, since a report no longer releases anything, and the query-db replay of an insert into a top-N list now expects the bounded repair's prefix and the new end's tie. Still open there: the `Object.is` term of the moved-end check. It skips work when the loader already read the end's tie, and it decides differently from its fallback only while other work is in flight, which the ignored-report law's quiet premise excludes. The ordered-work oracle owns that branch instead: peers over a refetching provider stay in its grammar for source filters (#2155 excludes only joined filters), and a proven-report law requires that a report which changes nothing the first query's own page and end tie prove, with the model's next row installed and no installed row deleted or reordered, starts no request for it and leaves it owing no `refetch: true` repair. A campaign fixed to that premise must reach it, and a loader that repairs every report fails both its fixed and random runs. A staged end tie that pending joined work defers is read by the pass after that work finishes; a request-authority named fixed history reaches it, and a loader that drops the staged tie fails it. The unproven-report law judges that later pass, and a truncate, failure, move, or disposal in between voids what the report owed. A history that starts during the initial preload found that a window move deferred behind a marker replay, which the source truncate then discarded, rejected with that replay's `AbortError`, so the older move's window won (also on main). A deferred move now waits for every replay that holds publication and applies unless one failed or cleanup ended the sync run; a named fixed history fails without the fix. The ordered-work refetch law also required no full-source read after a signalling refetch for an unindexed plan, which the architecture then let refill a short window from the full source; random seeds found it on main. Maintainer decision on this branch: an underfilled unindexed prefix never reads the full source, because that read is unbounded. It grows instead: each growth asks for twice the last prefix, and it stops once the last growth added no rows. A deletion sweep showed that the older condition's other term, a prefix that reads short, follows from that one. The refetch law now forbids a full-source read after a signalling refetch with no unindexed exception. The named fixed history of a short unindexed window and the fixed unindexed-refetch campaign (`ordered-work.eager-unindexed-refetch`, fixed and random, with reach on windows the prefix fills and windows it leaves short) failed on the full-source fallback and pass with growth; the full-source mutant still fails them. Growth made a new optimistic history possible: an unindexed one-row window behind a joined or anti-joined filter that installs only the prefix it grew to, whose departure leaves the replacement row missing. Sixteen fixed optimistic-window cases (update and delete, both consumers, acknowledge and rollback) cover it, with a refill bound of twice the installed prefix plus the overlaid row. A loader without the stop rule never reaches a fixed point; a loader that stops whenever the prefix reads short leaves those windows empty after settlement, because the pending departure hides a row from the count. The existing unindexed optimistic cases now allow, but do not require, a request behind a joined filter when the replacement row is installed: growth proves rows only up to its prefix, where the full-source fallback had proven every row (a maintainer-approved expectation change). The proven-report random campaign now declares its reach too. One mutant still changes requests without a law: dropping the optimistic-refill flag turns a refill into a refetching prefix. Six more are equivalent in the fake but stay, because the fake cannot reach their premise or they encode a documented rule: the own-first-page check behind a held sync transaction (the fake never holds one), a truncate moving complete coverage back to held, clearing the pending request when a full-source read starts (the fake never answers a full-source read synchronously), staging a joined `more` continuation, the staged-continuation guard before an unindexed prefix, and the drain re-entry guard (a long synchronous chain would otherwise recurse). An include dimension adds a `toArray` child that reads the marker source for each shown row, and an include-children law requires each shown row's markers to equal the model wherever the window law holds; a mutant that never loads newly demanded child keys fails it in an include-only scenario. A newest-move law requires a superseded window move to resolve only after the newest move settles (a superseded move that fails still rejects with its own error), and the model lets the newest move that resolved set the window; it found that a superseded move resolved as soon as its own load settled, so awaiting it showed neither window. The terminal-error law also allows `error` after a load fails while the query is still loading, the documented initial-load failure; a lazy join source is exempt. Limits: includes are one level deep. It also found that a release failure in lazy joined demand during a window move rejected the move, because the subscription reported it as a load error. A window law now lets the newest move win; it found that two moves deferred by a pending source recovery could resume in the opposite order, so the older request replaced the newer window. `setWindow()` now ignores a deferred call that a newer call superseded. A terminal-error law allows a final `error` only after a lazy join demand load, on either side, failed outside a truncate's recovery; named fixed histories reach that branch. A load belongs to a recovery when it asks again for a demand its source held before the truncate (the same request data), or when the graph starts it while the truncate commits. The first model read a flag set only during the commit, but subscriptions start replay loads in a later microtask, so it counted every replay load as new demand and allowed `error` after any failed replay load; a random campaign found the reverse case (#2224): a marker truncate privately moved the window, the include's new demand started outside any replay because releasing its old demand ended the subscription's replay, and its failure ended the query. The demand controller now starts new coverage before it releases retired coverage, a query subscription opens a replay at every truncate even without demand (a replay that starts no recovery work and reports no loading), and a demand failure that fails its subscription's replay keeps the last publication instead of ending the query once the query has first become ready. The section also generates histories that start during the initial preload, with an initial-readiness law (the preload has settled once loading is quiet), marker loads whose aborted requests reject with `AbortError` as the source contract asks, and settles of the newest held load. These found two older defects on main: an aborted lazy demand request that rejected after its truncate replay succeeded ended the query, and so did a superseded segment that the controller released; the controller now treats an aborted request as obsolete demand, which its replay or newer coverage decides. Named replays keep each trace; reverting each production change fails one of them, and a mutant whose replay rebuilds its request data fails the campaigns' replay-load reach witness. The ordered-lifecycle oracle owns the truncate-work witness: a zero-limit window issues no request and reports no loading at a truncate, while a one-row window reacquires. A retention law then judges law 9 at generated checkpoints: a truncate raises a publication barrier, every load its source starts while the barrier is up joins it, a participant's failure fails the replay while its logical demand is held, and while any barrier is up the query shows exactly the rows it showed when the first one rose. It runs after every command and once loading is quiet. A failed-replay campaign (`replay.ordered-failed-replay`, fixed and random) starts each history by holding loads, truncating a source, optionally starting demand, and failing a held load, with a branch that fails an include demand while its marker replay still loads; it declares four reach states with `createOracleReach` (a judged failed replay, a judged held replay, a demand started after the commit that fails while its replay loads, and the include form of that demand). A replay participant now also counts as recovery for the terminal-error law, so a demand that starts after the commit while its replay is open is judged in generated histories, not only by a named fixed history. A mutant whose failed replay releases the publication gate fails both failed-replay campaigns and the broad campaigns; a mutant that ends the query when such a demand fails fails both failed-replay campaigns; the branch's production files reverted to `main` fail both. A preload-recovery campaign (`replay.ordered-preload-recovery`, fixed and random) starts every history during the initial preload over on-demand markers and declares two more states: lazy demand in a replay that fails before the first ready, and a window move that waits behind a replay a newer truncate discards. A builder whose deferred move a discarded replay rejects, and one that keeps a failure before the first ready non-fatal, each failed only a named fixed history before; both now fail the fixed campaign, whose seed kills both (each dies in about half of 200-run seeds, so the random campaign kills each about half the time). Building the law found three model errors, each corrected from ARCHITECTURE.md: a failure is scoped to its logical demand, so a retired failed demand and the demand that replaces it publish when the replacement succeeds; a replay completes when its last participant's rows apply, so demand that a deferred window move starts afterwards does not join it; and an ordered tie request (a `where` on rank) is not lazy demand, which the terminal-error law had treated it as, so a failed tie request had excused a terminal error. A query's subscriptions to one collection now share one replay barrier (ARCHITECTURE.md, "Replay participants and failure"): a probe found that in a query that joins and includes the marker source, a marker truncate retired the include's only demand privately, its emptied replay closed while the join's replay still loaded, and include demand the graph then started failed outside every replay and ended the query. A sibling-demand branch of the failed-replay campaign declares that state; the branch's code before the fix fails the named fixed history and both campaigns at `convergence: terminal error`, and removing either sibling hold (at replay completion, or for an emptied replay) fails all three. The random failed-replay campaign then found the other half: one subscription's replay failed, the other's finished once it settled, and demand that a rank move then started on the finished subscription failed and ended the query, because the subscriber judged the failure by its own subscription. The subscriber now asks whether a replay of the source's collection failed; a named fixed history (`a demand that fails after the sibling subscription's replay failed`), a `failedSiblingDemandFailures` reach in the sibling branch (which now may move a row and fail the oldest held load), and both campaigns at 400 runs fail at `convergence: terminal error` on the per-subscription rule. A design that judged a failure against any failed replay of the query, across collections, used to survive, because the convergence check runs after an explicit window move, which retries a failed row demand. A strict terminal-error law now requires a failed lazy demand outside every recovery to end the query (ARCHITECTURE.md, error 228), skipping only failures the model cannot place: after a load threw, and, with a peer, a load that joined a replay. Merge with #2257: its review (CF01, [record](oracle-reviews/2026-10-26-pr-2257.md)) judged a new keyed request that a truncate commit starts as terminal, and called non-fatal recovery of that demand a future contract. This PR adopts that contract by maintainer decision. The merged owner keeps CF01's initiating-request receipt check as a second account of every terminal error, at each history cut and at convergence, plus its named fixed histories and the `replay.subscription-demand-origin` and `replay.keyed-request-failures` campaigns, with their two reach-state pairs measured on this owner's model. Both demand origins, new demand during a marker commit and a settled demand that the replay asks for again, must stay nonterminal after the first ready. An always-fatal `failDemand` mutant fails 15 tests, including both ported campaigns; a never-fatal mutant fails the strict law in 8. CF01's duplicate of the #2224 named fixed history is folded into this owner's existing one. Merge with #2168 (ARCHITECTURE.md law 16): after initial-query readiness no load failure ends a live query, and a failed lazy demand is a request error on the query's node. That supersedes the strict terminal-error law, the error-228 rule, and the always-fatal and never-fatal mutant results above for a query that has been ready. The replay owner now allows a terminal error only after a load failed while the query was still loading, judged at each history cut and at convergence, and it adopts #2168's request-error witness (`requestErrors`); the excusals for thrown and unplaced failures went with the strict law. #2168's design already starts new demand before it releases retired coverage, and it treats a synchronous `loadSubset` throw as a rejection whose owner's release unloads it (the subscription oracles fix that unload), so the adapter-release law now counts a thrown load as acquired. It lacked the shared barrier, the failure hold behind a sibling's failed replay, and the deferred window move's wait, which the merge ports onto it. Without the deferred-move loop, 8 tests fail, #2221 trace 2 among them; without `failReplacement`, the two-failures named fixed history fails; without the sibling wait, that replay and both failed-replay campaigns fail. A deferred move rejects when a reload ends the sync run of the query or of a source, which the source-health oracle requires. The window-request reset went: a move can no longer throw, and deleting it failed nothing. The replay that a truncate opened without demand went too: under law 16 its demand's failure no longer ends the query, and its named fixed history passes on #2168's subscription. Review of the merge (2026-10-28): the replay owner gained a request-error account. After initial-query readiness, each failed lazy demand load that is still owed must appear among the query node's open request failures, by its injected error object, at every history cut, and no request error may stay after recovery replays every source (authority: ARCHITECTURE.md law 16, law 17, and the glossary's request error). A failure is met when its demand retires, a later load of the source covers its keys, or a later replay of the source completes; a failure inside a replay owes nothing until every participant settled. A terminal error now needs a load that failed while that same query was loading, so a peer's initial failure no longer excuses `live`. A thrown load joins a replay that no participant joined yet, and the failed-sibling branch now generates a held retry. `requestErrorChecks` reaches the declared minimum of 5 in each failed-replay campaign. Mutants: demand outcomes that never report an error fail 9 tests at the per-cut check; a successful replay that keeps its request errors fails 22 at the stale-error check. Limits and open items: the account shares one replay across a query's subscriptions to a source, so it cannot judge one subscription's request error while another load of that source is held. The reviewer's finding that `failReplacement`'s replay, which has no recorded failure, completes when every demand that joined it settles is closed as a law revision by maintainer decision: the hold ends when every demand load that joins it loads, a later replay of the source succeeds, or the subscription's last demand retires (ARCHITECTURE.md, "Replay participants and failure"). The query retries the failed demand at once and that retry joins the hold, so the hold cannot publish without the failed demand's rows. The replay oracle judges each subscription's request-error verdict by the subscription that a load names, and its failed-replay campaigns declare the premise (a subscription's load succeeds while that subscription's failure is still owed; 30 fixed and 24 random histories at the normal budget). No live-query history violates the request-error or retention law there; a mutant that completes such a hold when any participant settles fails both failed-replay campaigns at the retention law. A subscription-level `it.fails` test that asserted the earlier, stricter wording (no joined demand may end the hold) was removed with the revision. Limit: the oracle drives the hold only through live-query histories, where the retry always joins; a direct `Collection.subscribeChanges` caller that never retries is outside the live-query contract. Which failure a deferred window move rejects with, when its replay has several, is not specified: `main` rejected with the replay's earliest failure, and this branch rejects with `lastSubsetError`; by maintainer decision the move rejects with its replay's earliest failure that still decides it (law 17 decides which count: a retired demand's, a retried request's, and a replaced demand's failures do not). An identity law now judges deferred moves that start before any failure of the replays they wait on; on the parent code both failed-replay campaigns and the named fixed history `a window move behind a replay with two failed loads` fail with "replay error identity", and the deferred-move loop that rethrows a non-abort replay error passes. The request-error account decides failures per key: a later load of the source that asks for some of a failure's keys decides them, and keys it no longer asks for left the demand. The held-retry branch then found, at three times the run budget, a marker change publishing over a window that the model retained after a failed marker replay (`hold, truncateMarkers, settle ok, settle ok, settle fail newest, mark 3 off`, ascending, offset 1, limit 1, includes over on-demand markers). It was a model defect: the query retries a failed demand at once, and when that retry loads the failed keys the failure no longer counts (ARCHITECTURE.md law 17), so the replay succeeds and publishes. The model now clears a replay failure when a later load of the replay loads its keys. The replay oracle passes at the normal budget and three times at three times it, and production without `failReplacement` still fails its named fixed history at the retention law. A move whose `setWindow` throws synchronously after the request generation increments is reachable only through invariant errors, which probes for a throwing subscriber, a crashed source, and a throwing `select` did not reach. An aborted request's rejection and the failure of a segment that newer coverage released are not failures. That design now fails both failed-replay campaigns. An independent oracle review then narrowed several judgments. The terminal-error excuse after a synchronous throw applies only to the thrown error itself, ending the query after the step that threw; the throw's own step, and every other failure, are judged strictly (a per-history flag had excused every later failure). A load that fails outside every barrier after a failed demand of its source retired is unplaced, because the failed replay may still hold through the subscription's other demands, which the adapter cannot attribute. Limit: with a peer, the strict law accepts either query ending, since the adapter boundary cannot tell which query's subscription issued a load; attributing each failure to its query's subscription would close that. The window check now also requires every shown row to be eligible and to appear once, so a tie cannot hide a row the join rejects. A synchronous-throw dimension (`markerThrows` arms the next marker load to throw while it starts) found that a load throwing inside an open replay ended the query: the subscription dropped the demand, so its failure did not fail the replay, and the subscriber treated the throw as fatal. The subscription now hands the replay a rejected participant for such a throw and keeps the demand, which a later replay retries; named fixed histories (`a replay load that throws while it starts`, `a demand whose load throws inside its open replay`), a `thrownReplayDemands` reach in a thrown-demand branch, and both failed-replay campaigns (now 600 runs) fail on the parent code, and a mutant that later unloads the never-acquired load fails the adapter-release check. Model corrections, each from the adapter-boundary rule that a thrown load acquires nothing: a thrown load joins a replay only if it still has a participant or a failure; an armed throw expires before the drain; and a thrown demand's retirement is invisible, so the retention law skips the marker barrier from a throw to the end of its episode (a later barrier in the episode inherits the ambiguity, which also corrects the existing release-throw case), and a failure that joins an already failed or ambiguous barrier may end the query. Maintainer decision: a synchronous throw inside an open replay fails that replay as a rejection does, so the subscription replay oracle's `new request throws` case and five `live-query-collection` cases, which expected a thrown new demand to roll back, now expect the rejection outcome. Limits: a design that dropped a thrown demand and let its replay succeed passes wherever only retention could see it; after a history command follows a throw, a terminal error is excused, because the model cannot see whether that command retired the thrown demand and with it its failed replay (a demand that waited on that replay then fails outside it). The named fixed histories judge the throw's own outcome strictly. The failed-replay random campaign at five times its run count then found a retention counterexample with no throw, on the parent code too: a join and an include of one marker source; one subscription's replay fails, the other's finishes, and a row move starts demand on it whose load fails, which the shared barrier kept from ending the query; a marker change then retired the first replay's failed demand, which discarded that replay and published an empty window, because the second failure held nothing. That failure now fails its own subscription's replacement, which holds until a later replay succeeds or the subscription's last demand retires. A named fixed history of the trace fails without the hold, and a `partialFailureRetirements` reach (a failed demand retires while another failure still holds the barrier) is declared in the failed-replay campaign; the campaigns themselves do not fail without the hold, so the named fixed history is the witness. A window move whose load throws while it starts counts as a settled, failed move in the model (it used to stay pending, so the newest-move law misjudged the older move), with a `thrownMoves` reach. The broad random campaign at five times its run count then found an older move whose settlement resolved with itself: the newer move threw after it bumped the request generation and before it replaced the latest request, so the older move's settlement returned its own promise, a chaining cycle that escaped as a TypeError. A call now clears the latest request when it bumps the generation, so no older move can wait on itself; a named fixed history fails without that. The same assignment covers a move deferred behind a replay, which had the same shape (an independent review found it can hang forever when a newer move throws between the replay settling and the deferred move resuming); that microtask limit is outside the grammar, which flushes between commands, so only the shared mechanism, not that path, has a witness. The ordered-work oracle also keeps the trace from #2221 as a named fixed history: before prefix growth the loader read the full source twice after a signalling refetch past a NaN boundary; with growth it reads none. Limits: the adapter boundary cannot tell which query issued a load, so the retention law and the participant rule skip histories with a peer. The retention law judges barriers that rise after the query first became ready; before that the initial-readiness law governs. It skips a barrier where a release threw while the replay started, which may fail that replay. Core now reports a throwing `unloadSubset` uncaught and fails nothing with it; the subscription lifecycle, replay, Effect disposal, includes-temporal, and subset-error oracles check the uncaught report, once, with the adapter's error. The request-authority oracle now also generates requests that throw while they start, the last history only TLA+ covered, with an error-identity law: a failed request's participant reports that request's own error. It found that a superseded failure whose recovery read threw at its start reported the start error instead (review finding 6). The state oracle compares every current failed request's participant reason with its own originating Error by identity. Release cleanup cannot replace that reason, even when the thrown value's message starts with “release ”. A bounded 16-case checker calibration crosses index availability, widths 1/2, actual throwing release absent/present, and ordinary/full-source plans. It feeds three wrong participant reasons into the same model/production-driver/comparison; the original prefix waiver and an actual-release-only waiver fail at that check. The existing fixed/random 10,000 authority histories declare and enforce current-request-error reach and actual cleanup-fault/current-request-error overlap. Scope ends at the loader participant and separate uncaught-cleanup observations; it adds no provider/framework or intermediate-publication promise. The state owner's shared error-identity calibration and paired generated campaigns enforce these checks. A cost law then checks, after loading is quiet and a bounded repair is available, that one invalidation reads no full source and issues at most one chain per ordering epoch move (in the fake, a prefix, a tie, and a refill); a mutant that always repairs with a full-source read fails it. The replay-refinement section's status law requires a quiet live query and its peer to stay ready when no load failed. Limits: whether a release runs the graph at once or defers it to a transaction belongs to the scheduler, and the oracle generates both forms without checking the scheduler's choice; the fake's rows do not move except for the unexpressible boundary; publication holds stay with other tests; the laws do not judge requests issued before a truncate; a hung authoritative request suspends the leak law for its unfinished chain; a recovery start that throws during a superseded failure is rare in the campaigns, so a named fixed history owns it. | | Indexed predicate filtering | Complete for bounded dotted-path collision | An independent row filter checks direct predicates and public callback predicates for a dotted scalar field beside a nested path, with and without an index. Selected-field ordering checks the same distinction through `$selected`. | | WHERE predicate publication | Complete for bounded predicate and sync-transaction grammar | The contract, Kleene reference evaluator, snapshot and change-history grammar, three subscriber consumers plus direct snapshot, and per-commit key-set refinement check are literate. A fixed same-key update checks live-row and direct-subscriber payloads; a focused descriptor boundary checks stored-row prefilter safety. Generated cleanup and restart histories for filtered subscribers remain open. | @@ -576,20 +584,20 @@ that test identifiers must copy production's private data structures. | Grouped result keys | [Grouped result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/group-result-key-oracle.property.test.ts) | A grouped live-query Collection gives each group one key of the Collection key type, a string or a number, whatever the group value. Maintainer decision (2026-10-27): grouped keys match the declared key type rather than widening it to nullish keys. Group values come from strings, numbers, `NaN`, `null`, `undefined`, booleans, a bigint, strings that print like those values, and strings in the reserved `\u0000tanstack-db:` namespace; histories move, delete, and insert rows. After preload and each synced change, the (group value, count) pairs equal an independent partition by type and value, every key is a string or number, no two groups share a key, and a group that survives a change keeps its key. Reach (minimum 5, per campaign of 80): a nullish group published (32 to 38), a group value published beside a string that prints like it (18 to 21), and a key kept across a change (49 to 53). RED on the parent commit: keys `undefined`, `null`, `false`, and `10n`. Mutants that encode with `String(value)` (lookalike keys collide) or that encode only nullish values (boolean keys) fail. Encoding only the internal D2 key is equivalent in this domain, because the Collection keys rows by the public group key. A focused witness in `collection.test.ts` checks that `sync.write` rejects `undefined`, `null`, boolean, and bigint keys before staging them, and that a later valid write commits alone. A sync adapter that writes such a key breaks its contract, so this is a fail-fast invariant witness under AGENTS.md ("Separate Valid Edge Cases from Contract Contradictions"), not a generated owner; SortedMap never receives these keys. Not covered: Dates, `-0`, objects, and multi-field groups, whose values the aggregate select oracle owns; real sync adapters, since the source is a controlled mock. | | D2 Index storage | Complete for bounded prefix grammar | The contract, plain-`Map` multiset model, prefixed and unprefixed value grammar, `Index` driver, and per-addition `get`/`has` check are literate. Compaction, presence tracking, and structural payloads remain outside this owner. | | Pooled live queries | Complete for bounded eq-filter grammar | The contract, independent `eq` model, live-query Collection second formulation, sync/optimistic/mount/cleanup-restart grammar, observer driver, and per-step refinement check are literate. On-demand and persisted sources, `DbClient`, Suspense, and every clause beyond `eq` conjuncts keep the live-query Collection. | -| Live-query deferred acquisition | Complete for bounded synchronous grammar | The law, independent model, history grammar over source states, shapes, and depth, real-Collection driver, and per-step refinement check are literate. Pooled views and DbClient stream preloads have their own blocks. The grammar also adds a peer consumer starting the source and a source truncate before the first subscriber; pinned blocks cover a subscriber that outlives cleanup, reads that wait for readiness, and release of two independent descriptor sources when one start fails. Asynchronous sources, several live-query consumers, and general failure histories stay open. | +| Live-query deferred acquisition | Complete for bounded synchronous grammar | The law, independent model, history grammar over source states, shapes, and depth, real-Collection driver, and per-step refinement check are literate. Pooled views and DbClient stream preloads have their own blocks. The grammar also adds a peer consumer starting the source and a source truncate before the first subscriber; fixed blocks cover a subscriber that outlives cleanup, reads that wait for readiness, and release of two independent descriptor sources when one start fails. Asynchronous sources, several live-query consumers, and general failure histories stay open. | | Flat-row change tracking | Complete for bounded flat-row grammar | Flat and proxy trackers and an independent change model run the same generated callbacks. Nested values fall back to the proxy, which its own oracles own. | -| Production error messages | `packages/db/tests/production-error-messages-oracle.test.ts` | Every public error class keeps its development message for frozen sample inputs, and its coded production line shows the JSON of each shown input, even for hostile inputs. Every plain `Error`, `TypeError`, `RangeError`, or `AggregateError` site in `packages/db/src` keeps its frozen development template, has a code, and passes each showable interpolated expression or one of its sub-expressions; the TypeScript checker keeps plain objects, such as rows, out of production lines. A projection of an object, such as `Object.keys(handlers)`, is not required. Every `console` call whose argument holds library text is coded, development-only with its text frozen per guarded region, or a development-only decoration of a value. Text that reaches the console through a parameter, such as `setErrorState(message)`, is coded where it is written; the census cannot follow it. `scripts/test-production-errors.mjs` checks that a production bundle drops the full text and every development-only hint, in ESM and CommonJS. Errors built from a caller's value stay outside this owner. | +| Production error messages | `packages/db/tests/production-error-messages-oracle.test.ts` | Every public error class keeps its development message for frozen sample inputs, and its coded production line shows the JSON of each shown input, even for adversarial inputs. Every plain `Error`, `TypeError`, `RangeError`, or `AggregateError` site in `packages/db/src` keeps its frozen development template, has a code, and passes each showable interpolated expression or one of its sub-expressions; the TypeScript checker keeps plain objects, such as rows, out of production lines. A projection of an object, such as `Object.keys(handlers)`, is not required. Every `console` call whose argument holds library text is coded, development-only with its text frozen per guarded region, or a development-only decoration of a value. Text that reaches the console through a parameter, such as `setErrorState(message)`, is coded where it is written; the census cannot follow it. `scripts/test-production-errors.mjs` checks that a production bundle drops the full text and every development-only hint, in ESM and CommonJS. Errors built from a caller's value stay outside this owner. | | Lazy target path identity | Focused compiler boundary | A same-source union/coalesce witness keeps dotted and nested demand paths distinct during target deduplication. | | Correlated include path identity | Focused public route-context witnesses | One-level and nested includes keep dotted and nested parent paths, including ancestor aliases, distinct. Conditional result paths receive separate routes. Fixed fixtures cover initial reads and selected source updates; other recursive source forms and arbitrary path segments remain outside this witness. | -| Alias scope identity | Generated cross-scope alpha-renaming and finite exact-output refinement | Optimizer copies, wraps, and collapses keep `SourceId`; captured refs retain lexical bindings under ancestor shadowing; ancestor/descendant source declarations need distinct builders while sibling placements may reuse one; public aliases remain visible. The bounded paths and remaining gaps are in the owner row. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds finite self-join, joined-context, and captured whole-row witnesses. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) covers absent RIGHT/FULL sides and captured ancestor join operands. | +| Alias scope identity | Generated cross-scope alpha-renaming and finite exact-output refinement | Optimizer copies, wraps, and collapses keep `SourceId`; captured refs retain lexical bindings under ancestor shadowing; ancestor/descendant source declarations need distinct builders while sibling placements may reuse one; public aliases remain visible. The bounded paths and remaining limits are in the owner row. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds finite self-join, joined-context, and captured whole-row witnesses. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) covers absent RIGHT/FULL sides and captured ancestor join operands. | | Join equality and cold acquisition | Complete | Independent cold relational recomputation, acquisition evidence, established equality domains, replacements, and scan/index routes are literate. | | Opaque backend pagination | Complete | The full-relation value model plus opaque token, cache generation, publication, browser acquisition, and live-window integration owners are literate. | | Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | | PowerSync | Fixed tests only | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are covered by fixed tests in `correctness.test.ts`; no oracle owns them (tracked in #2264). | | SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared-handle driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | -| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. A reach check requires interruptions before admission, between admission and the provider call, and after it, for each interruption. Mutants that drop the execution loop's fence, resolve before deletion, drop the admission check, or check admission before awaiting initialization fail it. Open there: shared keys, providers that answer later, and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. The [FIFO retry oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) is the `oracleProperty` `offline.fifo-retry`: each transaction's planned retriable failures, permanent failure, and phase-write or deletion fault are history data, and a model of the README's FIFO, retry, and settlement contract judges calls, settlements, and the outbox at the deadline cuts and after every retry. Reach comes from production. Four states are state probes in `TransactionExecutor` (`offline-transactions#retry-before-peer`, `#permanent-failure-before-peer`, `#phase-write-fault`, `#fault-before-peer`); `predict` says from the plans alone which histories enter each, and reach agreement must hold in every history. The storage fake reports `deletion fault` when it refuses the removal, and the driver reports `retried head` and `retried tail` as measured states. Four constructed histories enter the gating states on every run, and each campaign reaches every gating state at least 13 times. Mutants that let a ready tail pass the delayed head, retry without backoff, drop a permanent failure's caller rejection, swallow a phase-write error, keep an outbox row after removal, keep running after a storage fault, or keep arming a retry timer once nothing is left to run fail the oracle, and misplaced or removed probes fail reach agreement. The idle law (an executor with nothing left to run holds no timer) is the maintainer's decision of 2026-11-06: an idle spin is a bad bug. The driver runs at most 1,000 retry timers after the deadline, one at a time, so an executor that never goes quiet ends the history at once and the idle law judges it; while work is pending, timer counts and polling strategy stay outside the judgment. A mutant that keeps the scheduler's active ID after a failure stalls the driver, so it is killed only by the test timeout, not by a named law. Open there: retry hooks, leadership changes, and storage faults on a permanent failure's removal. The check labels a call after a storage fault as FIFO order rather than the stop law. The [interruption settlement oracle](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts) is the `oracleProperty` `offline.interruption-settlement`. It moves one leadership loss, offline change, or disposal across individual microtask turns after one to three commits on disjoint keys, with storage and provider answers immediate or released by a generated data schedule (storage in order, the provider before or after any pending storage operation), then recovers by regaining leadership, going online, or restarting over the same storage. It requires that no provider call or new outbox admission starts while fenced, that each admitted ID reaches the provider once and in commit order, that a fulfilled commit's row is already gone from storage, that commits settle with their provider outcome after a regain or reconnect, and that the outbox ends empty. Reach is measured per campaign for interruptions before admission, between admission and the provider call, and after it, for each interruption; eight fixed histories enter every reachable landing on every run. Mutants that drop the execution loop's fence, drop the admission check, or check admission before awaiting initialization fail it; an un-awaited outbox deletion passes the immediate timing and fails the scheduled one (law durability). Open there: shared keys and overlapping old and new executors. The settlement oracle's generated FIFO histories draw each transaction as one record (outcome; manual commit, auto-commit with the option set or omitted, or an `OfflineAction`; receipt read or not), use `when('settled')` as an auto-commit's receipt, and require that no failure escapes as an unhandled rejection, also when nobody reads the receipt, and that settlement logs no failure again and writes no debug output. An empty transaction on any commit path may precede each one; its receipt and completion waiter must fulfil without reaching the provider or the outbox. A manual transaction that writes a row and rolls back before it commits may also precede each one; its waiter must reject with an Error that names the rollback. A focused witness rolls a transaction back while it persists, for both provider outcomes: its waiter must wait for the executor and report the executor's outcome. After every transaction settles, a new waiter for each one must reject within a turn rather than hang. An unread receipt still settles with its own outcome when read afterwards, and a caller may register its completion waiter before admission on every path but `OfflineAction`. A focused witness fails the admission outbox write on every commit path and requires the receipt to reject with the storage error, without an unhandled rejection or a repeated log; a failed outbox write stops the executor, so generated histories do not continue past one. Open: the generated failure source is the mutation function only; manual commits always follow their own `mutate()` at once, so a pending manual transaction behind a failing shared-key sibling is not generated; a throwing `mutate()` callback is not generated, because core keeps its transaction pending; a rolled-back-before-commit transaction is generated only on the manual path, with its own key and its waiter registered after `mutate()`, so a secondary rollback of a pending transaction by a failing sibling, whose waiter gets the generic rollback Error, is not generated. | | Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | -| Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. | +| Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. MultiSet consolidation (`ivm.multiset-consolidate`, [owner](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts)) keeps one record per identity under the keyed, primitive, or structural rule, with the first record and the summed multiplicity, and leaves its input unchanged. Six declared states are production probes (`db-ivm#...` in `packages/db-ivm/src/multiset.ts`): the three rules, `records merged`, `join tuples merged`, and `only pairs without a key`. The model predicts each one from the history alone, and reach counts a state only where the probe and the prediction agree (`reach agreement`). Each state has a constructed history, so every campaign enters it; the grammar modes are generator dimensions, not reach. Sixteen mutants are killed, each by both campaigns: wrong identity rules, merge sums, retained records, zero-sum output, -0 handling, input mutation, and wrong or missing probe placements. Limits: output order is not part of the law; structural identity uses a 32-bit hash, so no injectivity is claimed; the probes are development-only behind each function's `probing` constant (the production bundle equals the bare-guard bundle, which drops every probe, up to identifier names), and they show where production enters a state while the check judges the output. The empty multiset takes rule 1 by the law's vacuous "every record" reading, which no output depends on. | | Mixed-kind ordered prefixes | Bounded | Eager BasicIndex and BTreeIndex paths compare ascending and descending direct and live-query windows with an unindexed scan across mixed value kinds and paired source changes. One retained on-demand BTree history checks settled windows after a number/bigint transition, a finite request for a missing row, and a cursor request after index eligibility changes. Other provider paging histories remain open. | | Boundary refinements | Complete | Cleanup/restart admission, metadata publication, retained state, acquisition cells, D2 source reconciliation, top-K support windows, and nested Query work bounds are literate. | | Small structures and test mechanics | Complete | SortedMap, cleanup appointments, and guarded replay are literate. | @@ -616,29 +624,29 @@ records the rejection policy and exact calibration. The eight-item follow-up at reviewed head `16824f677` extends the same owners. The cleanup/restart owner adds six plain/live-query logical-demand cases at idle, ready, and cleaned-up cleanup start, plus six admission outcomes that -distinguish a pending cleanup promise from an actual replacement sync run. -The deferred-acquisition owner adds render-deferred eager/on-demand sources and -explicit deferred subscription/resumption histories across both query shapes -and one/two live-query levels. The React descriptor driver receives resumption -after a real abandoned suspended render through the existing startup judgment. -The observer-history owner adds eight failed-setup histories, with both modes, +distinguish a pending cleanup promise from an actual replacement sync run. The +deferred-acquisition owner adds render-deferred eager/on-demand sources and +explicit deferred subscription/resumption histories across both query shapes and +one/two live-query levels. The React descriptor driver receives resumption after +a real abandoned suspended render through the existing startup judgment. The +observer-history owner adds eight failed-setup histories, with both modes, one/two failures, cleanup rejection or real sync-start failure. Client, status, and layout leases are checked immediately, then retry, publication and release. Two late-listener seed failures retain a healthy peer and exclude the failed listener from later publications. Eight stream-preload histories cross pending or successful streams, both source modes and a render deferral. The real React streaming test receives the on-demand premise while retaining server rows until -browser acquisition completes. -The pooled release owner adds twelve new/retained/released partition histories -through held loading/ready cleanup and two startup-cleanup reentry histories. -The subscription boundary owner adds 28 resumption/retirement histories crossing -loading, ready and acquiring notifications, cleanup, source mode and render gate. -The shared token-count model forbids restoring a retired owner. Deferred-demand -cancellation fixtures now create logical demand before explicit resumption; -render-owned queued loads remain covered by the DbClient fixture. These finite -extensions do not establish arbitrary async/provider schedules or new framework -error policy. The [follow-up audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) -records original RED, hostile controls, and the unchanged rejection decision. +browser acquisition completes. The pooled release owner adds twelve +new/retained/released partition histories through held loading/ready cleanup and +two startup-cleanup reentry histories. The subscription boundary owner adds 28 +resumption/retirement histories crossing loading, ready and acquiring +notifications, cleanup, source mode and render gate. The shared token-count +model forbids restoring a retired owner. Deferred-demand cancellation fixtures +now create logical demand before explicit resumption; render-owned queued loads +remain covered by the DbClient fixture. These finite extensions do not establish +arbitrary async/provider schedules or new framework error policy. The +[follow-up audit](oracle-reviews/2026-10-22-dbclient-migration-feedback.md) +records original RED, mutant controls, and the unchanged rejection decision. A subsequent current-head summary exposed cleanup during ownership transfer. The deferred-acquisition owner adds 192 surviving-owner histories crossing both @@ -827,9 +835,10 @@ updates are observed. The primary includes owner supplies an independent active-interval model: retained routes share one ready facade, retired handles stay ready/empty, and a new interval gets a new handle. This assumes no facade-local optimistic mutations. Every shape declares active empty readiness -and nonempty child receiving cuts. Six lifetime and four inline-value hostile -controls reject wrong observations. See [chunk 5](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). -Functional callback-input observations have a separate receiving boundary in +and nonempty child receiving cuts. Six lifetime and four inline-value mutant +controls reject wrong observations. See +[chunk 5](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md). Functional +callback-input observations have a separate receiving boundary in `virtual-row-fields-input-oracle.test.ts`, sharing the existing source metadata judgment. The maintainer's 2026-11-05 boundary-only decision supersedes O12's October 28/30 callback-normalization policy. Intermediate callback inputs, @@ -837,11 +846,11 @@ inline values and Effect outputs retain authored fields, including explicit reserved-name overrides. Direct source rows retain genuine metadata. Published Collection metadata attribution remains with the publication owner above. Thirty-three authoring -descriptions cross seven QueryRef/join/union paths, five callback forms -(select, where, having, where-select, having-select), and root, wrapped child, -direct child and real Effect receivers. Flat union children require a named -QueryRef for include correlation; direct flat union children remain inadmissible. -The finite grammar has 4,290 receiving histories and 20 direct-source controls. +descriptions cross seven QueryRef/join/union paths, five callback forms (select, +where, having, where-select, having-select), and root, wrapped child, direct +child and real Effect receivers. Flat union children require a named QueryRef +for include correlation; direct flat union children remain inadmissible. The +finite grammar has 4,290 receiving histories and 20 direct-source controls. Each receiver checks immutable callback captures and complete derived or predicate-filtered output at initial readiness, held mutation, settlement and @@ -948,7 +957,7 @@ every TS version, arbitrary extra-property structural assignment, or the internal semantics of trusted PendingMutation/LoadSubsetOptions carriers. The [type-preservation audit](oracle-reviews/pr-2259-persisted-option-types.md) and `scripts/calibrate-persisted-option-types.mjs` preserve parent RED, GREEN, -legacy-suite survival, hostile-mutant evidence, and runnable overlay recipes. +legacy-suite survival, mutant-mutant evidence, and runnable overlay recipes. The readonly-row overlay strips user-property readonly modifiers from get's return while retaining readonly virtual fields. The prior grammar accepted it; the modifier histories reject it at the shared exact published-row comparison. @@ -967,26 +976,27 @@ The versioned audit records the user decision, original overwrite RED, GREEN, and guards that check only own/truthy properties or omit a reserved name. Other existing fixed type checks cover adjacent compile-time boundaries: -`packages/db/tests/mutation-handler-type.test-d.ts` (fixed tests; no oracle owner (tracked in #2264)) checks sibling -Collection/key/utilities in mutation handlers; and +`packages/db/tests/mutation-handler-type.test-d.ts` (fixed tests; no oracle +owner (tracked in #2264)) checks sibling Collection/key/utilities in mutation +handlers; and `packages/db/tests/query/virtual-row-fields-reference-types.test-d.ts` retains concrete helper examples. The generated public type owner is `packages/db/tests/query/virtual-row-fields-types-oracle.test.ts`. Its 24 finite row descriptions vary key domain, scalar type, nested optionality and discriminated union membership. Shared runtime/type authoring dimensions cross -five projection shapes and eight compositions; seven child shapes cross -the legal facade/toArray/materialize/findOne forms; array/Date facades are -rejected inputs. Each generated body also runs public query/index construction, -with an explicit index type and failure-preserving cleanup. Two admission -controls reject opaque facades and optionless indexes at their actual boundary. -A reusable recursive type signature checks -values, exact keys, required/optional and readonly modifiers per union member, -without rejecting duplicate equivalent union members. Public-source imports -compile through the actual TypeScript compiler; all diagnostics retain their -file, span and code. Seven hostile controls challenge value enrichment, -nullability, one-branch readonly loss, nested any/metadata/modifiers and Date -metadata any. Their rejection requires failed type-law constraints, not syntax -or setup errors. The normal test:oracles command selects this owner. +five projection shapes and eight compositions; seven child shapes cross the +legal facade/toArray/materialize/findOne forms; array/Date facades are rejected +inputs. Each generated body also runs public query/index construction, with an +explicit index type and failure-preserving cleanup. Two admission controls +reject opaque facades and optionless indexes at their actual boundary. A +reusable recursive type signature checks values, exact keys, required/optional +and readonly modifiers per union member, without rejecting duplicate equivalent +union members. Public-source imports compile through the actual TypeScript +compiler; all diagnostics retain their file, span and code. Seven mutant +controls challenge value enrichment, nullability, one-branch readonly loss, +nested any/metadata/modifiers and Date metadata any. Their rejection requires +failed type-law constraints, not syntax or setup errors. The normal test:oracles +command selects this owner. The 2026-11-05 boundary-only implementation extends the primary owner above with 48 authored collision/path/selection states and 18 grouping/collision @@ -1177,49 +1187,52 @@ delete, and move parents, insert and delete children, and move the window, with executed fixed cases for each premise, and every checkpoint asserts `getWindow()`. A `findOne` keeps its compiled limit (1, or 0 for `.limit(0).findOne()`); an ordered `findOne` has a movable window, and an -unordered one is a fixed one-row result that accepts only its compiled limit -at offset 0. Rows must equal -a relational model. Preload may load only the children of the shown rows' -groups, and a step only those of rows that entered the window and were not -shown before it. A static filter creates static child demand before the -query runs only when no enclosing query is windowed or a `findOne`, and either the query is neither windowed nor a `findOne`, or it is a `findOne` that shows one row for one named correlation value, with no `offset`, and offset 0 at start if a window was requested before start. Pinned witnesses show each case -the rule closes: a window requested before start (12 child rows for a bound of -2 before the rule), grandchild demand below a windowed parent, a `findOne` with +unordered one is a fixed one-row result that accepts only its compiled limit at +offset 0. Rows must equal a relational model. Preload may load only the children +of the shown rows' groups, and a step only those of rows that entered the window +and were not shown before it. A static filter creates static child demand before +the query runs only when no enclosing query is windowed or a `findOne`, and +either the query is neither windowed nor a `findOne`, or it is a `findOne` that +shows one row for one named correlation value, with no `offset`, and offset 0 at +start if a window was requested before start. Fixed histories show each case the +rule closes: a window requested before start (12 child rows for a bound of 2 +before the rule), grandchild demand below a windowed parent, a `findOne` with `limit(0)`, and a `findOne` moved past its one named value before start; a detail page whose window before start is at offset 0 still starts its child acquisition attempt at once. A focused test requires `setWindow` to throw `SetWindowFindOneLimitError` for any other `findOne` limit and -`SetWindowRequiresOrderByError` for an offset move on an unordered `findOne`. A `findOne` detail page with on-demand parent and child starts -its child acquisition attempt beside its parent's. On main, a late page -delivered 120 child rows for a bound of 9. Routers that add the row after the -window fail the generated campaigns; routers that add every row in an edge tie -group fail a pinned collapse and the detail page; a child that takes over -`setWindow` fails the rows law. Focused tests require a window inside a `from` -or joined subquery not to answer `setWindow`. +`SetWindowRequiresOrderByError` for an offset move on an unordered `findOne`. A +`findOne` detail page with on-demand parent and child starts its child +acquisition attempt beside its parent's. On main, a late page delivered 120 +child rows for a bound of 9. Routers that add the row after the window fail the +generated campaigns; routers that add every row in an edge tie group fail a +fixed collapse and the detail page; a child that takes over `setWindow` fails +the rows law. Focused tests require a window inside a `from` or joined subquery +not to answer `setWindow`. Recorded limit (#2151): while an index-backed parent top-K refills within one graph run (a held row moved out, a tie group loaded at the window edge, a -forward window move), it can briefly hold a row outside the window it shows -once its ordered acquisition completes, and load that row's children; the -public snapshot stays correct. A refill checkpoint is a model observation that -combines an ordered acquisition and whether it completed: the parent Collection -delivers more change batches than the initial load or the step's own parent -write. The generated campaigns skip only its work bound. In the fixed campaign -(60 runs, seed 2137), the work bound checks 285 of 290 checkpoints strictly: -preload 60 of 60, parent inserts 45 of 45, parent deletes 34 of 35, rank moves -43 of 43, child inserts 36 of 36, child deletes 37 of 37, backward window moves -18 of 18, and forward window moves 12 of 16. Six replays pin the known traces -and allow exactly the one extra child row each shows; with no overshoot -allowed, each fails. Four pinned forward moves (local and on-demand children, -with and without `distinct`) allow none. Each generated campaign counts the -forward moves its own runs check strictly and fails if it counts none. +forward window move), it can briefly hold a row outside the window it shows once +its ordered acquisition completes, and load that row's children; the public +snapshot stays correct. A refill checkpoint is a model observation that combines +an ordered acquisition and whether it completed: the parent Collection delivers +more change batches than the initial load or the step's own parent write. The +generated campaigns skip only its work bound. In the fixed campaign (60 runs, +seed 2137), the work bound checks 285 of 290 checkpoints strictly: preload 60 of +60, parent inserts 45 of 45, parent deletes 34 of 35, rank moves 43 of 43, child +inserts 36 of 36, child deletes 37 of 37, backward window moves 18 of 18, and +forward window moves 12 of 16. Six fixed histories cover the known traces and +allow exactly the one extra child row each shows; with no overshoot allowed, +each fails. Four fixed forward moves (local and on-demand children, with and +without `distinct`) allow none. Each generated campaign counts the forward moves +its own runs check strictly and fails if it counts none. Open in-scope paths, all owned by this includes-work owner unless noted: refill checkpoints need the #2151 fix (holding include demand until the ordered acquisition completes) before the work bound can check them; includes inherited from a `from` subquery and inputs to `fn.select` still route from every row before the window, and need generated forms under the work bound; the -edge tie group router is caught only by pinned cases, and needs a generated tie +edge tie group router is caught only by fixed cases, and needs a generated tie at the window edge on a checkpoint without a refill; a child compile error after the parent's `orderBy` still leaves the parent's auto-index (owner: compiler tests), and needs a focused witness that the index does not outlive a failed @@ -1428,46 +1441,64 @@ accepting, rejecting, and absent outer predicates; a materialized-Collection formulation supplies matching and nonmatching controls. Other `singleResult` forms, join forms, and scheduling cuts remain outside these owners. -The offline scheduler owner (`packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts`) -checks FIFO eligibility and retry order at the scheduler boundary. It does not -observe the executor's awaited outbox update between `updateTransaction` and +The offline scheduler owner +(`packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts`) +checks FIFO eligibility and retry order at the scheduler boundary, as the +`oracleProperty` `offline.key-scheduler-fifo-ledger`. Before its port the +generator drew histories of at most 12 commands (fast-check's default array +size) with a biased selector, so 4 of 150 histories per campaign started any +transaction and 1 retried; generation now draws 1 to 50 unbiased commands, and +about a quarter of histories start work and about one in fifteen retries. +Histories also duplicate a pending ID, as outbox replay does, and schedule +`missing`, the ID replay snapshots name without the scheduler holding it. +`schedule` must return false for a duplicate (law `dedupe`, authority: +TransactionExecutor uses that result to find newly loaded transactions); +keeping the first record on a duplicate awaits a maintainer decision. The +declared state `duplicate schedule` is the first state bound to a production +probe: the model predicts which histories duplicate a pending ID, the +scheduler's development-only `duplicate-schedule` probe reports which ones +production rejected, and the two must agree in every history (law `reach +agreement`). A constructed history fills all five slots and then duplicates +one, so every campaign reaches `duplicate schedule at capacity`, which the +driver records. Reach for command types comes from the commands the driver +executed. It does not observe +the executor's awaited outbox update between `updateTransaction` and `markFailed`, or public caller settlement. Those receiving cuts belong with `packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts`. The transaction serializer owner uses controlled Temporal constructors and fake storage; a claim about native Temporal or browser storage needs a receiving -witness in those runtimes. -The offline settlement owner follows the README's success boundary: the -configured `mutationFn` has returned and the storage adapter has acknowledged -outbox deletion. Its controlled storage histories hold and fail deletion, -stop the executor and queued peers on a storage failure, reject new admission, -and restart after a persisted `deletion-pending` phase. A marked row skips provider replay -even when `beforeRetry` filters all ordinary pending work; an old unmarked row -still replays. Adapter tests prove that IndexedDB transaction failure and -localStorage `removeItem` failure reject rather than masquerade as deletion. -When a caller removes an active row with `removeFromOutbox` or `clearOutbox` -while its provider is held, the caller remains pending until that provider -returns. The controlled witness then checks provider calls, public settlement, -and the empty outbox. The leadership owner checks that an earlier -`deletion-pending` row with the same persisted timestamp is deleted before its -later peer reaches the provider. This restart witness uses storage insertion -order to distinguish creation order; independent storage writers and native -browser hosts remain outside its claim. -These witnesses do not establish physical power-loss durability, a real -provider's idempotency, or the crash window between provider completion and -the phase write. The provider must honor the supplied idempotency key in that -window. Older application versions that do not understand the phase can also -replay a marked row. Native browser restart and multi-owner handoff of the -marked phase remain receiving witnesses for this owner. - -The same owner also needs a captured failing seed-and-shrink-path replay for -its generated settlement property. The +witness in those runtimes. The offline settlement owner follows the README's +success boundary: the configured `mutationFn` has returned and the storage +adapter has acknowledged outbox deletion. Its controlled storage histories hold +and fail deletion, stop the executor and queued peers on a storage failure, +reject new admission, and restart after a persisted `deletion-pending` phase. A +marked row skips provider replay even when `beforeRetry` filters all ordinary +pending work; an old unmarked row still replays. Adapter tests prove that +IndexedDB transaction failure and localStorage `removeItem` failure reject +rather than masquerade as deletion. When a caller removes an active row with +`removeFromOutbox` or `clearOutbox` while its provider is held, the caller +remains pending until that provider returns. The controlled witness then checks +provider calls, public settlement, and the empty outbox. The leadership owner +checks that an earlier `deletion-pending` row with the same persisted timestamp +is deleted before its later peer reaches the provider. This restart witness uses +storage insertion order to distinguish creation order; independent storage +writers and native browser hosts remain outside its claim. These witnesses do +not establish physical power-loss durability, a real provider's idempotency, or +the crash window between provider completion and the phase write. The provider +must honor the supplied idempotency key in that window. Older application +versions that do not understand the phase can also replay a marked row. Native +browser restart and multi-owner handoff of the marked phase remain receiving +witnesses for this owner. + +The same owner also needs a captured failing seed-and-shrink-path replay for its +generated settlement property. The [deletion-settlement review record](oracle-reviews/2026-09-30-offline-deletion-settlement.md) preserves its earlier executable revision. The [fail-stop follow-up](oracle-reviews/2026-09-30-offline-fail-stop-followup.md) -records the revised storage-failure law, RED/GREEN witnesses, and limits. -The guarded replay owner (`packages/db/tests/oracle-replay.test.ts`) now checks -that named pagination and index campaigns execute only the requested replay -lane. An unrelated failing fixed campaign no longer runs first. The +records the revised storage-failure law, RED/GREEN witnesses, and limits. The +guarded replay owner (`packages/db/tests/oracle-replay.test.ts`) now checks that +named pagination and index campaigns execute only the requested replay campaign. +An unrelated failing fixed campaign no longer runs first. The [prep-PR review record](oracle-reviews/2026-09-30-prep-pr-followup.md) records these four review repairs at their exact executable revision. @@ -1481,7 +1512,7 @@ does not award oracle credit for a filename alone. | ------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [#1831](https://github.com/TanStack/db/pull/1831) | Fixed tests; no oracle owner (tracked in #2264) | PowerSync's `packages/powersync-db-collection/tests/correctness.test.ts` (fixed tests; no oracle owner (tracked in #2264)) crosses real PowerSync receipts and native SQLite behavior. It runs in the package test campaign; portable declarations retain compiler authority. | | [#1832](https://github.com/TanStack/db/pull/1832) | Equivalent specialized authority | `packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts` and `packages/electric-db-collection/e2e/subset-sql-acceptance.e2e.test.ts` run through the package's real-provider `test:e2e` campaign. Compiler unit tests are collateral, not substitutes for either service boundary. | -| [#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. | +| [#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, mutant 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. Every generated campaign in these owners uses the shared `TANSTACK_DB_ORACLE_{SEED,PATH,PROPERTY}` replay interface, with one property per campaign (`offline.fifo-retry`, `offline.leadership.*`, `offline.serializer.*`, `offline.settlement.*`). | @@ -1607,7 +1638,8 @@ is not browser evidence. `hydration.svelte.test.ts` separately receives normalized transport and normal warm-browser initialization through its native scheduler. -The [React SSR diagnostic receiver](../../packages/react-db/tests/ssr-effects-oracle.test.ts) +The +[React SSR diagnostic receiver](../../packages/react-db/tests/ssr-effects-oracle.test.ts) extends that preparation grammar and judgment with real matched React/ReactDOM 18.3.1 and 19.3.1 development server renderers. Separate paths cover provider and explicit-client ordinary hooks, completed non-streaming Suspense through @@ -1619,8 +1651,9 @@ prepared Suspense compares rows. Standalone/supplied paths make no hydration claim. Fixed/fresh campaigns retain per-runtime/path reach and direct replay; this does not establish streaming Suspense, actual browser hydration, arbitrary provider delivery or every React version. Client commit and abandonment remain -with the window receiving owner. [SSR effect evidence](oracle-reviews/2026-10-30-react-ssr-effects.md) -records pre-edit RED, repaired GREEN and hostile rejection. +with the window receiving owner. +[SSR effect evidence](oracle-reviews/2026-10-30-react-ssr-effects.md) records +pre-edit RED, repaired GREEN and mutant rejection. The shared admission owner also defines runtime ready/error outcomes, browser explicit preparation and defined/undefined causes. Its receiving drivers are @@ -1960,13 +1993,13 @@ server/local behavior has independent function refs. On unchanged production, 640 admission assertions fail and 1,526 tests pass, including six controls. Failures remain open until the deferred production repair. The shared immutable bound-options checker now receives Preact's six native histories as well as -React's 12; six hostile in-place mutations are rejected. Svelte's actual +React's 12; six mutant in-place mutations are rejected. Svelte's actual client-backed effect receives all 144 cached-option histories. All three native selectors pass. Portable first claims/failure/consumption are no longer merely proposed coverage. Remaining cache limits at that chunk were first registration -without an existing stream, arbitrary transport schedules, schema transforms -and native partial-construction cleanup. Svelte SSR/hydrateRoot/continuing publications are -not supplied by its browser admission receiver. +without an existing stream, arbitrary transport schedules, schema transforms and +native partial-construction cleanup. Svelte SSR/hydrateRoot/continuing +publications are not supplied by its browser admission receiver. Chunk 12 adds first registration without a transported query result. Core runs 4,320 histories: explicit preload, streaming observer preload and server read @@ -1990,66 +2023,64 @@ concurrent schedules and other framework first-registration receivers remain outside this bounded evidence. Native partial-construction evidence is recorded separately in chunk 11. -`packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` also checks -a two-source descriptor union when the first sync start fails: the second source +`packages/db/tests/live-query-deferred-acquisition-oracle.test.ts` also checks a +two-source descriptor union when the first sync start fails: the second source still starts and reaches ready on direct preload, and a preparation error stays primary if a deferred source also throws during release. The controlled sources start synchronously. At the synchronous preload error cut, even a caller that opts to stream pending queries cannot dehydrate the failed query; a later same-hash preload over a healthy replacement publishes its own row. Asynchronous failure is also exercised with a later controlled `markError`: the pending -stream rejects and disappears from dehydration, the peer source is ready, and -a healthy same-hash replacement publishes its row. A mutant that dehydrates +stream rejects and disappears from dehydration, the peer source is ready, and a +healthy same-hash replacement publishes its row. A mutant that dehydrates errored records fails at that cut. Other asynchronous schedules and larger -source sets remain open. Retrying the same options after the -source entered terminal error reports that source error instead of reusing the -failed query's pending stream; it does not restart the source. -A replacement observer subscribed after the controlled asynchronous failure -can attach under the same hash and reads the healthy replacement row as ready, -without a stale stream error, before a new client preload. The prior -source-mismatch check fails that attachment assertion. The same history rejects -a different-source preload while its original stream is still pending. A second -fixed history crosses a synchronous source throw and then attaches a healthy -replacement observer before a new client preload; its settled snapshot contains -only the replacement row. Removing the failed-record source-claim guard fails -that observer attachment, while ignoring every existing stream's claim fails -the pending-stream rejection. The preload-identity owner also checks active -streams. These histories do not enumerate concurrent replacement observers, -other failure schedules, or later conflicting preloads while an observer has no -reusable client stream. -An observer preload over a config-free pooled view is also explicit demand: it +source sets remain open. Retrying the same options after the source entered +terminal error reports that source error instead of reusing the failed query's +pending stream; it does not restart the source. A replacement observer +subscribed after the controlled asynchronous failure can attach under the same +hash and reads the healthy replacement row as ready, without a stale stream +error, before a new client preload. The prior source-mismatch check fails that +attachment assertion. The same history rejects a different-source preload while +its original stream is still pending. A second fixed history crosses a +synchronous source throw and then attaches a healthy replacement observer before +a new client preload; its settled snapshot contains only the replacement row. +Removing the failed-record source-claim guard fails that observer attachment, +while ignoring every existing stream's claim fails the pending-stream rejection. +The preload-identity owner also checks active streams. These histories do not +enumerate concurrent replacement observers, other failure schedules, or later +conflicting preloads while an observer has no reusable client stream. An +observer preload over a config-free pooled view is also explicit demand: it starts an idle eager source once, does not restart a running source, and reaches a ready public snapshot in both cases, with or without an SSR cleanup client. This bounded witness does not claim the same path for on-demand or asynchronous -sources. -`packages/svelte-db/tests/descriptor-query-release.svelte.test.ts` (fixed tests; no oracle owner (tracked in #2264)) -checks Svelte's two-source release after a first startup error and a direct -reader after a second descriptor factory fails. It observes source start counts, -ready status, and failed-hook subscription release. A fresh effect root after -the failed root is retired consumes an independent healthy query and its row. -A controlled Svelte runtime probe showed that an uncaught effect error does not -rerun the same effect when its reactive query changes; in-place recovery after -that error is not promised. This owner does not establish two-client Svelte -provider binding or ordinary reactive query replacement. Concrete- -config descriptors, on-demand acquisition, and those Svelte paths remain open -for this binding law; a -receiving oracle needs the relevant public observation for each. Vue, Solid, -and Angular have no DbClient descriptor-binding contract in this work. Their -query callers use concrete Collections. -`packages/db/tests/live-query-options.test.ts` checks that a client-aware -consumer binds nested, union-branch, and include-child descriptors during final -preparation. Construction retains descriptor refs in those positions, including -direct, nested, materialized, and conditional includes. A prepared builder is a -bound snapshot; a later descriptor join remains unbound until another -client-aware consumption. The focused core tests check source materialization -and sync starts before and after release of a render deferral set, plus the -missing-client error for direct consumption. The React descriptor oracle checks -public rows after a committed hook consumes such a continuation and zero source -starts when a render containing one never commits. These bounded checks do not -compare union or include rows under separate clients, or concurrent render -schedules. The original standalone Query remains the reusable two-client plan; -rebinding a previously prepared snapshot to a different client is not claimed. -`packages/db/tests/query/collection-ref-compatibility.test.ts` pins the +sources. `packages/svelte-db/tests/descriptor-query-release.svelte.test.ts` +(fixed tests; no oracle owner (tracked in #2264)) checks Svelte's two-source +release after a first startup error and a direct reader after a second +descriptor factory fails. It observes source start counts, ready status, and +failed-hook subscription release. A fresh effect root after the failed root is +retired consumes an independent healthy query and its row. A controlled Svelte +runtime probe showed that an uncaught effect error does not rerun the same +effect when its reactive query changes; in-place recovery after that error is +not promised. This owner does not establish two-client Svelte provider binding +or ordinary reactive query replacement. Concrete- config descriptors, on-demand +acquisition, and those Svelte paths remain open for this binding law; a +receiving oracle needs the relevant public observation for each. Vue, Solid, and +Angular have no DbClient descriptor-binding contract in this work. Their query +callers use concrete Collections. `packages/db/tests/live-query-options.test.ts` +checks that a client-aware consumer binds nested, union-branch, and +include-child descriptors during final preparation. Construction retains +descriptor refs in those positions, including direct, nested, materialized, and +conditional includes. A prepared builder is a bound snapshot; a later descriptor +join remains unbound until another client-aware consumption. The focused core +tests check source materialization and sync starts before and after release of a +render deferral set, plus the missing-client error for direct consumption. The +React descriptor oracle checks public rows after a committed hook consumes such +a continuation and zero source starts when a render containing one never +commits. These bounded checks do not compare union or include rows under +separate clients, or concurrent render schedules. The original standalone Query +remains the reusable two-client plan; rebinding a previously prepared snapshot +to a different client is not claimed. +`packages/db/tests/query/collection-ref-compatibility.test.ts` fixes the exported `IR.CollectionRef` shape for concrete Collections: `collection` is an enumerable, writable own field that a spread copies. It also checks that an unbound descriptor uses `IR.DescriptorRef` with no `collection` field. A bounded @@ -2060,56 +2091,57 @@ generic visitor behavior outside the named collectors. | Surface | Primary executable owners | Independent judgment and important limit | | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [fractional-index window moves](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts) | Independent ordered relations and cumulative signed output. Each generated incrementalization campaign sums the checker's delivery counts and requires one atomic delivery per checkpoint and more split deliveries than an unsplit run makes; a checker that stops splitting fails all six campaigns. Most ordered steps edit the previous world by one removal or insertion, and a reconstruction check counts promotions of retained rows over the executed fixed campaign (25 global and 21 per parity at 100 runs; independent worlds gave 2 and 2). A type test pins `join(other, type)`'s nullable pair sides for every join type. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. The fractional-index test checks an empty output message for a window move after all rows retract; it does not prove downstream lazy acquisition. The [distinct incrementalization extension](../../packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts) recomputes unique immutable `{id: string, value: integer}` selections across real hash collisions, with two unequal collision members and a constructively verified noncolliding anchor. Its shared [model and grammar](../../packages/db-ivm/tests/distinct-incrementalization-oracle.ts) vary collision-member order, atomic versus separate insertion, duplicate support, partial and last removal, replacement, cancellation, and reinsertion, with a noncolliding anchor. The existing incrementalization checker compares net weighted deltas and retained rows after every logical batch in atomic and legal unit-split D2 deliveries, for selected-value and default-tuple distinct. The [public receiving witness](../../packages/db/tests/query/distinct-collision-oracle.test.ts) checks the same law and histories after every applied source transaction through ordered and unordered live-query Collections; the [reported seeded pair](../../packages/db/tests/query/distinct-reported-collision.test.ts) replays that shared judgment. Each campaign declares and measures collision coexistence, duplicate support, partial removal, last removal, and reinsertion separately for its receiving path. The original hash-only operator, equality buckets with shared output keys, and counts capped at one all fail value comparisons. These checks do not promise representative choice for unselected fields, intermediate source-transaction publications, framework render scheduling, arbitrary payload types, or collision freedom of the digest. The [issue 2306 audit](oracle-reviews/2026-10-30-distinct-collisions.md) records RED, GREEN, mutants, bounds, and byte cost. The wider representative and includes receiving lanes are recorded under [Distinct collision review extension](#distinct-collision-review-extension-2026-10-30). Other hash-based operators remain outside this distinct repair; the Index structural-collision trace is recorded there with its owner. Algebra does not specify client readiness. The BTree/Map oracle checks point operations, neighbor pairs, and full, partial, boundary, exclusive-high, and empty range scans for integer keys and node sizes 4–8. It does not check balance or space, and it assumes consistent comparators ([code-weight BTree review](oracle-reviews/code-weight-btree-trim.md)). The generated groupBy law compares JSON values; a pinned contract test in `packages/db-ivm/tests/operators/groupBy.test.ts` records that after a delete, `min` and `max` return a value hash-equal to a remaining member, not necessarily the remaining instance (`-0` for `0`, an equal Date), which is why the query compiler keeps its own exact-value keys. It checks both insertion orders, reads the group's row by integrating output multiplicities, deletes the minimum and the maximum, and shows that an aggregate input that tells the rows apart returns the remaining instance, for -0/0 in both directions and for both `min` and `max`. The contract holds while every member has positive multiplicity. It pins the current behavior separately: the delete's retraction is the last record a shared hash entry gets, so the deleted instance comes back in either insertion order, for -0/0 in either direction and for an equal Date. For -0/0 and equal Dates the contract assertions only show hash equality, which is all the contract promises. Open: the incrementalization law's value policy compares JSON and clones by `structuredClone`, so no generated history sees -0 or Date identity; a value-identity policy with -0/0 and equal-time Dates would generalize this pinned contract. | -| Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. Observe raw promised order; fresh queries do not establish continuous publication safety. The temporal owner's source-readiness lane enumerates every legal order of delivery, settlement, source readiness, and one later change for a source whose Collection readiness lags its settled subsets. It checks live-query readiness, preload settlement, and visible rows for direct, single-result, ordered-window, include-child, lazy-join, and eager-root sources; `queryOnce` results for the direct and eager-root shapes; `toArrayWhenReady()` and `stateWhenReady()` results for a read started after rows are visible but before readiness, over the direct, single-result, and eager-root shapes (and the lazy join for `toArrayWhenReady()`); and Effect `skipInitial` deltas for the direct, lazy-join, and eager-root shapes, and every on-demand cell of every consumer is crossed with a truncate or (join and include) a parent rekey before settlement. Each on-demand cell also compares its consumer with a metamorphic twin over a source that is ready from its start, which does not rely on the model's eager/on-demand rule. A live-query observer test checks that a hydration seed hands off at subset settlement, and an Electric test pins that a ready progressive query's rows update only at the full sync's swap. Effects over an ordered window or an include have no lane. It rejects both a design that waits for every source's Collection readiness and one that ignores source readiness. Electric live-query tests are the receiving witnesses for a real progressive source and an on-demand source that is ready before its subset loads; a React Suspense test checks that Suspense and `isReady` release together. Other framework adapters have no Suspense witness. | -| Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [GC and persisting transactions](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-transaction-oracle.property.test.ts), [GC retention boundaries](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.property.test.ts), [shared GC model](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.ts), [GC mutation projection](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-mutation-oracle.property.test.ts), [native idle receiver](https://github.com/TanStack/db/tree/main/packages/db/e2e/collection-gc), [initial GC review evidence](oracle-reviews/pr-2258-gc-retention.md), [second GC review and decisions](oracle-reviews/pr-2258-gc-retention-round2.md), [interaction and mutation receiving evidence](oracle-reviews/pr-2258-gc-retention-round3.md), [GC checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.test.ts), [Electric confirmation receiver](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Every bounded change-event history, and about half of each generated campaign, runs again on a local-only Collection that ends with `reload()`: it must resolve with the Collection `ready`, the change-message mirror of valid messages must equal the public rows, the public rows must equal the history's kept rows (maintainer decision: a local-only reload keeps the user's writes, and `initialData` loads only into a Collection that starts empty), and a later write must reach both. The adapter that re-inserted `initialData` in each run failed 210 histories (a crash when a kept row changed, or a mix of kept rows and `initialData`); a reset to `initialData` fails 344, and loading `initialData` whenever the Collection is empty fails 45 (a history that deleted every row). Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A ready callback that throws while entering sync surfaces when the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). The GC property (`collection-lifecycle.gc-persisting-transaction`) checks on a fake clock that automatic garbage collection retires a Collection only after `gcTime` with no subscriber and no persisting transaction, that the delay restarts when the last transaction completes or fails, and that explicit `cleanup()` still retires it while a transaction persists. Histories run over two Collections, with inserts through one Collection's `onInsert` or through a manually committed transaction that touches both. After each subscribe, unsubscribe, insert, settlement, and clock step it compares each Collection's `cleaned-up` status and each persisting row's visibility with an independent deadline model. `main` before the fix, a hold that never restarts the delay, a restart only on completion, and a restart only for the first Collection a transaction touched each fail it. The companion `collection-lifecycle.gc-retention-boundaries` property separates elapsed eligibility from host idle delivery. Its shared reference model checks the absolute 50ms startup/preload handoff, real last unsubscribe, pending versus persisting transactions, delayed commit, success/failure/rollback, surviving siblings, disabled GC, and old idle work after renewal. Generated configured delays are 1, 10, 49, 50, 51, and 100ms; observations include immediately before and at the independent deadline. Eager sync and write-started sync both run. A Chromium receiving driver uses the same model/checker with native idle delivery and cancellation and controlled elapsed timers. It records the actual budget core reads and checks public status at core delivery and a usable native observation opportunity. Exact wall-clock cleanup latency is not promised. Paced mutations, offline transaction restoration, live-query Collections, and actual Electric `awaitTxId` plus provider delivery are not generated by these GC owners. The boundary grammar now generates repeated warm preloads over queued idle and persisting holds, successful and failed cold preloads as first or last releases, both sibling settlement orders, and pending drafts held by subscriptions. Held adapter cleanup crosses settlement with surviving pending or persisting siblings, both before and after cleanup settlement. Exact key comparisons reject leftover settled rows and cleanup resurrection; tracked transaction identities still match unsettled transactions. The 250-history fixed and random campaigns require all 33 declared states. Alongside the 15 focused lanes, constructive interaction histories combine up to seven transactions and three subscribers, delayed commit, three persisting siblings, non-FIFO settlement and preload between releases. Automatic GC also starts held or rejecting adapter cleanup, checks synchronous row discard before adapter settlement, and rejects pending-sibling resurrection. The shared model additionally receives direct updates/deletes, shared update/delete and cancelled insert/delete ownership with a surviving peer update through `collection-lifecycle.gc-mutation-shapes`. Its independent source/intent projection checks exact keys and values across completion/failure/rollback, both Collection orientations and subscriber states; each 150-history campaign requires all 11 declared states. The maintainer chose first-usable-idle retirement as a fixture scheduling assumption, not a public liveness guarantee. The maintainer also chose caller-owned pending draft visibility: keep a subscription; restart after GC does not promise draft restoration. The boundary grammar therefore does not establish post-GC draft restoration, paced/offline restoration, or arbitrary cleanup/source-recovery histories. A real Electric/Postgres receiving driver now crosses gcTime 10/100ms with absent/last-unsubscribed owners, holds an assigned uncommitted txid across the unheld deadline, and observes pending caller, persisting phase, optimistic row, genuine confirmation, successful settlement, synced row, renewed GC and idle delivery. Its 12 paths retain four one-write cases and add eight two-write cases with separate real SQL transactions and both local settlement orders. A genuine acknowledgement releases only one hold; the other SQL transaction remains open beyond the first-release deadline. It shares the model/checker and rejects no-hold and first-release runtime mutants. Native host and real provider are witnessed separately; their combined browser/provider schedule, three-or-more provider writers, provider failure/recovery and persistence wrappers remain outside these bounded receiving histories. The Collection lifecycle GC owners still use fresh disjoint optimistic keys. Before claiming the combined same-key rollback-cascade or confirming-sync-queue law with GC, this owner needs a generated receiving history that crosses those semantic owners at retention and cleanup checkpoints. The history owner's demand-change grammar (`demandChangeLifecycleHistoryArbitrary`, a fixed seed and the random campaign `subscription-lifecycle.demand-change`) adds a third demand, a `replace` command (`replaceSnapshots`: a held replacement whose own load did not fail covers the owners it replaces, law 10), and a `change` command that retires several owners through one `releaseSnapshot(...wheres)` call; the model judges an open replay once after the whole change (law 9). Declared reach: a change during a replay, a replacement covering a failed owner, and a change whose verdict between releases would differ. Per-release judging, first-release-alone, and batching only two-owner changes each fail it. A covered owner's failed replay load reports `loadSubset:error` whatever its settlement order (the model's existing reporting rule; suppressing the last settler fails it), and a prompt cancellation of a covered owner settles the replay as any settlement does. An aborted held replacement covers nothing: the abort ended its load before it applied (maintainer decision: an abort ends the load; law 10), so its replaced owner's failure keeps the replay failed (two 30-times counterexamples, now `keeps a replay failed when a replacement aborted after its replaced owner failed` and its peer-loaded twin; RED on the first simplification, which counted the aborted replacement as covering; a mutant that drops the check fails both). The controller always aborts and releases a segment together, so it never makes that case. An abort ends the load (maintainer decision): the history model's `abort`, and a truncate that replaces an attempt, stop that attempt's gating at once in both `manual` and `reject` cancellation; an owner whose attempt the abort ended before it settled owes the replay nothing, and a rejection after the abort decides nothing (a failure settled before the abort stays). The publication model finishes a replacement at an abort, and the replay oracle's newer attempt drops the older attempts' pending loads. A synchronous throw from `loadSubset` is a rejection (maintainer decision): an acquisition that core releases, also in the acquisition-cell and replay witnesses. RED on 2bc79fc7c: 18 lifecycle tests, 1 refinement test. Mutants that fail: a replay that waits for an aborted load (14), an aborted rejection reported as `loadSubset:error` (19), an aborted load that still holds readiness (17). | +| Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [fractional-index window moves](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts) | Independent ordered relations and cumulative signed output. Each generated incrementalization campaign sums the checker's delivery counts and requires one atomic delivery per checkpoint and more split deliveries than an unsplit run makes; a checker that stops splitting fails all six campaigns. Most ordered steps edit the previous world by one removal or insertion, and a reconstruction check counts promotions of retained rows over the executed fixed campaign (25 global and 21 per parity at 100 runs; independent worlds gave 2 and 2). A type test fixes `join(other, type)`'s nullable pair sides for every join type. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. The fractional-index test checks an empty output message for a window move after all rows retract; it does not prove downstream lazy acquisition. The [distinct incrementalization extension](../../packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts) recomputes unique immutable `{id: string, value: integer}` selections across real hash collisions, with two unequal collision members and a constructively verified noncolliding anchor. Its shared [model and grammar](../../packages/db-ivm/tests/distinct-incrementalization-oracle.ts) vary collision-member order, atomic versus separate insertion, duplicate support, partial and last removal, replacement, cancellation, and reinsertion, with a noncolliding anchor. The existing incrementalization checker compares net weighted deltas and retained rows after every logical batch in atomic and legal unit-split D2 deliveries, for selected-value and default-tuple distinct. The [public receiving witness](../../packages/db/tests/query/distinct-collision-oracle.test.ts) checks the same law and histories after every applied source transaction through ordered and unordered live-query Collections; the [reported seeded pair](../../packages/db/tests/query/distinct-reported-collision.test.ts) replays that shared judgment. Each campaign declares and measures collision coexistence, duplicate support, partial removal, last removal, and reinsertion separately for its receiving path. The original hash-only operator, equality buckets with shared output keys, and counts capped at one all fail value comparisons. These checks do not promise representative choice for unselected fields, intermediate source-transaction publications, framework render scheduling, arbitrary payload types, or collision freedom of the digest. The [issue 2306 audit](oracle-reviews/2026-10-30-distinct-collisions.md) records RED, GREEN, mutants, bounds, and byte cost. The wider representative and includes receiving grammars are recorded under [Distinct collision review extension](#distinct-collision-review-extension-2026-10-30). Other hash-based operators remain outside this distinct repair; the Index structural-collision trace is recorded there with its owner. Algebra does not specify client readiness. The BTree/Map oracle checks point operations, neighbor pairs, and full, partial, boundary, exclusive-high, and empty range scans for integer keys and node sizes 4–8. It does not check balance or space, and it assumes consistent comparators ([code-weight BTree review](oracle-reviews/code-weight-btree-trim.md)). The generated groupBy law compares JSON values; a fixed contract test in `packages/db-ivm/tests/operators/groupBy.test.ts` records that after a delete, `min` and `max` return a value hash-equal to a remaining member, not necessarily the remaining instance (`-0` for `0`, an equal Date), which is why the query compiler keeps its own exact-value keys. It checks both insertion orders, reads the group's row by integrating output multiplicities, deletes the minimum and the maximum, and shows that an aggregate input that tells the rows apart returns the remaining instance, for -0/0 in both directions and for both `min` and `max`. The contract holds while every member has positive multiplicity. It records the current behavior separately: the delete's retraction is the last record a shared hash entry gets, so the deleted instance comes back in either insertion order, for -0/0 in either direction and for an equal Date. For -0/0 and equal Dates the contract assertions only show hash equality, which is all the contract promises. Open: the incrementalization law's value policy compares JSON and clones by `structuredClone`, so no generated history sees -0 or Date identity; a value-identity policy with -0/0 and equal-time Dates would generalize this fixed contract. | +| Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. Observe raw promised order; fresh queries do not establish continuous publication safety. The temporal owner's source-readiness grammar enumerates every legal order of delivery, settlement, source readiness, and one later change for a source whose Collection readiness lags its settled subsets. It checks live-query readiness, preload settlement, and visible rows for direct, single-result, ordered-window, include-child, lazy-join, and eager-root sources; `queryOnce` results for the direct and eager-root shapes; `toArrayWhenReady()` and `stateWhenReady()` results for a read started after rows are visible but before readiness, over the direct, single-result, and eager-root shapes (and the lazy join for `toArrayWhenReady()`); and Effect `skipInitial` deltas for the direct, lazy-join, and eager-root shapes, and every on-demand cell of every consumer is crossed with a truncate or (join and include) a parent rekey before settlement. Each on-demand cell also compares its consumer with a metamorphic twin over a source that is ready from its start, which does not rely on the model's eager/on-demand rule. A live-query observer test checks that a hydration seed hands off at subset settlement, and an Electric test checks that a ready progressive query's rows update only at the full sync's swap. Effects over an ordered window or an include have no grammar. It rejects both a design that waits for every source's Collection readiness and one that ignores source readiness. Electric live-query tests are the receiving witnesses for a real progressive source and an on-demand source that is ready before its subset loads; a React Suspense test checks that Suspense and `isReady` release together. Other framework adapters have no Suspense witness. | +| Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [GC and persisting transactions](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-transaction-oracle.property.test.ts), [GC retention boundaries](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.property.test.ts), [shared GC model](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.ts), [GC mutation projection](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-mutation-oracle.property.test.ts), [native idle receiver](https://github.com/TanStack/db/tree/main/packages/db/e2e/collection-gc), [initial GC review evidence](oracle-reviews/pr-2258-gc-retention.md), [second GC review and decisions](oracle-reviews/pr-2258-gc-retention-round2.md), [interaction and mutation receiving evidence](oracle-reviews/pr-2258-gc-retention-round3.md), [GC checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-retention-oracle.test.ts), [Electric confirmation receiver](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/collection-gc.e2e.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Every bounded change-event history, and about half of each generated campaign, runs again on a local-only Collection that ends with `reload()`: it must resolve with the Collection `ready`, the change-message mirror of valid messages must equal the public rows, the public rows must equal the history's kept rows (maintainer decision: a local-only reload keeps the user's writes, and `initialData` loads only into a Collection that starts empty), and a later write must reach both. The adapter that re-inserted `initialData` in each run failed 210 histories (a crash when a kept row changed, or a mix of kept rows and `initialData`); a reset to `initialData` fails 344, and loading `initialData` whenever the Collection is empty fails 45 (a history that deleted every row). Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A ready callback that throws while entering sync surfaces when the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). The GC property (`collection-lifecycle.gc-persisting-transaction`) checks on a fake clock that automatic garbage collection retires a Collection only after `gcTime` with no subscriber and no persisting transaction, that the delay restarts when the last transaction completes or fails, and that explicit `cleanup()` still retires it while a transaction persists. Histories run over two Collections, with inserts through one Collection's `onInsert` or through a manually committed transaction that touches both. After each subscribe, unsubscribe, insert, settlement, and clock step it compares each Collection's `cleaned-up` status and each persisting row's visibility with an independent deadline model. `main` before the fix, a hold that never restarts the delay, a restart only on completion, and a restart only for the first Collection a transaction touched each fail it. The companion `collection-lifecycle.gc-retention-boundaries` property separates elapsed eligibility from host idle delivery. Its shared reference model checks the absolute 50ms startup/preload handoff, real last unsubscribe, pending versus persisting transactions, delayed commit, success/failure/rollback, surviving siblings, disabled GC, and old idle work after renewal. Generated configured delays are 1, 10, 49, 50, 51, and 100ms; observations include immediately before and at the independent deadline. Eager sync and write-started sync both run. A Chromium receiving driver uses the same model/checker with native idle delivery and cancellation and controlled elapsed timers. It records the actual budget core reads and checks public status at core delivery and a usable native observation opportunity. Exact wall-clock cleanup latency is not promised. Paced mutations, offline transaction restoration, live-query Collections, and actual Electric `awaitTxId` plus provider delivery are not generated by these GC owners. The boundary grammar now generates repeated warm preloads over queued idle and persisting holds, successful and failed cold preloads as first or last releases, both sibling settlement orders, and pending drafts held by subscriptions. Held adapter cleanup crosses settlement with surviving pending or persisting siblings, both before and after cleanup settlement. Exact key comparisons reject leftover settled rows and cleanup resurrection; tracked transaction identities still match unsettled transactions. The 250-history fixed and random campaigns require all 33 declared states. Alongside the 15 focused grammar, constructive interaction histories combine up to seven transactions and three subscribers, delayed commit, three persisting siblings, non-FIFO settlement and preload between releases. Automatic GC also starts held or rejecting adapter cleanup, checks synchronous row discard before adapter settlement, and rejects pending-sibling resurrection. The shared model additionally receives direct updates/deletes, shared update/delete and cancelled insert/delete ownership with a surviving peer update through `collection-lifecycle.gc-mutation-shapes`. Its independent source/intent projection checks exact keys and values across completion/failure/rollback, both Collection orientations and subscriber states; each 150-history campaign requires all 11 declared states. The maintainer chose first-usable-idle retirement as a fixture scheduling assumption, not a public liveness guarantee. The maintainer also chose caller-owned pending draft visibility: keep a subscription; restart after GC does not promise draft restoration. The boundary grammar therefore does not establish post-GC draft restoration, paced/offline restoration, or arbitrary cleanup/source-recovery histories. A real Electric/Postgres receiving driver now crosses gcTime 10/100ms with absent/last-unsubscribed owners, holds an assigned uncommitted txid across the unheld deadline, and observes pending caller, persisting phase, optimistic row, genuine confirmation, successful settlement, synced row, renewed GC and idle delivery. Its 12 paths retain four one-write cases and add eight two-write cases with separate real SQL transactions and both local settlement orders. A genuine acknowledgement releases only one hold; the other SQL transaction remains open beyond the first-release deadline. It shares the model/checker and rejects no-hold and first-release runtime mutants. Native host and real provider are witnessed separately; their combined browser/provider schedule, three-or-more provider writers, provider failure/recovery and persistence wrappers remain outside these bounded receiving histories. The Collection lifecycle GC owners still use fresh disjoint optimistic keys. Before claiming the combined same-key rollback-cascade or confirming-sync-queue law with GC, this owner needs a generated receiving history that crosses those semantic owners at retention and cleanup checkpoints. The history owner's demand-change grammar (`demandChangeLifecycleHistoryArbitrary`, a fixed seed and the random campaign `subscription-lifecycle.demand-change`) adds a third demand, a `replace` command (`replaceSnapshots`: a held replacement whose own load did not fail covers the owners it replaces, law 10), and a `change` command that retires several owners through one `releaseSnapshot(...wheres)` call; the model judges an open replay once after the whole change (law 9). Declared reach: a change during a replay, a replacement covering a failed owner, and a change whose verdict between releases would differ. Per-release judging, first-release-alone, and batching only two-owner changes each fail it. A covered owner's failed replay load reports `loadSubset:error` whatever its settlement order (the model's existing reporting rule; suppressing the last settler fails it), and a prompt cancellation of a covered owner settles the replay as any settlement does. An aborted held replacement covers nothing: the abort ended its load before it applied (maintainer decision: an abort ends the load; law 10), so its replaced owner's failure keeps the replay failed (two 30-times counterexamples, now `keeps a replay failed when a replacement aborted after its replaced owner failed` and its peer-loaded twin; RED on the first simplification, which counted the aborted replacement as covering; a mutant that drops the check fails both). The controller always aborts and releases a segment together, so it never makes that case. An abort ends the load (maintainer decision): the history model's `abort`, and a truncate that replaces an attempt, stop that attempt's gating at once in both `manual` and `reject` cancellation; an owner whose attempt the abort ended before it settled owes the replay nothing, and a rejection after the abort decides nothing (a failure settled before the abort stays). The publication model finishes a replacement at an abort, and the replay oracle's newer attempt drops the older attempts' pending loads. A synchronous throw from `loadSubset` is a rejection (maintainer decision): an acquisition that core releases, also in the acquisition-cell and replay witnesses. RED on 2bc79fc7c: 18 lifecycle tests, 1 refinement test. Mutants that fail: a replay that waits for an aborted load (14), an aborted rejection reported as `loadSubset:error` (19), an aborted load that still holds readiness (17). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | | React paced-mutation render cuts | [render-cut oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx), [PR #2170 review](oracle-reviews/pr-2170-react-paced-render-cuts.md) | `usePacedMutations` publishes callbacks only from committed renders and does so before descendant layout effects, while callback-only commits preserve mutate identity. An independent committed-document model crosses ordinary and StrictMode schedules with mutation before or after a suspended candidate, mutation after the candidate commits, and delayed or immediate mutation from a descendant layout effect. It compares ordered callback traces and public rows at the mutation cut, the trace after persistence, and mutate identity at the commit cut. Render-time publication, layout-effect-only publication, and split insertion/layout publication mutants fail at the intended comparisons. A commit between transaction admission and persistence, arbitrary React schedules, server delivery, and other frameworks remain outside this receiving owner. | -| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | -| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: [#2349](https://github.com/TanStack/db/issues/2349) reproduces an optimistic nested sibling edit converting an untouched class/null-prototype leaf to a plain record, then restoring its shape at sync confirmation. The maintainer identified this as a bug and explicitly kept its repair outside #2346. The draft/revert owners must add generated sibling edits and pending/confirmed shape observations while preserving isolation. A draft also reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | +| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [core property](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.property.test.ts) (`optimistic-history.core`), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | `optimistic-history.core` runs the core grammar through `oracleProperty`: the history resolves to a plan of concrete actions, the driver records each checkpoint, and the model owes allowed cuts, rows, outcomes, receipts, and tracked transactions, judged as named laws; the former settlement-boundary regressions are its fixed histories, and the seven observation mutants are calibrations, each rejected by its named law. Its grammar does not yet generate pending manual edits, external completion, rejected handlers, reentrant drains, partial updates, ordered operations, or schema defaults; those dimensions are still covered only by fixed histories in the owners that drive `runOptimisticHistory` (open: move them into the grammar). Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction grammar also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `when('settled')`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; the generated ownership histories read every receipt themselves, so only a focused witness (rolling back an empty manual transaction) checks that an unread receipt reaches no `unhandledrejection`; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| oracleProperty framework | [self-oracle](https://github.com/TanStack/oracles/blob/98a9610465f802707b4ca95cbe75ca7f1bbbce2f/packages/oracles/tests/oracle-property-self-oracle.test.ts) (`self-oracle.framework`), [synthetic properties](https://github.com/TanStack/oracles/blob/98a9610465f802707b4ca95cbe75ca7f1bbbce2f/packages/oracles/tests/oracle-property-self-oracle-fixture.ts), both in TanStack/oracles at the pinned commit | The framework lives in `@tanstack/oracles` (TanStack/oracles); `packages/db/tests/oracle-property.ts` only re-exports it after loading TanStack DB's `oracle-config.ts`, so its self-oracle runs in TanStack/oracles, not in this repository. `oracleProperty` promises its authors registration, verdicts, shrinking, membership, reach, calibration, determinism, timeouts, replay, and rerun commands. The self-oracle writes synthetic properties (histories of one to three integers), runs them under real Vitest in a child process, and compares each test's state and failure with an independent model that reads only the synthetic driver's log of the histories each test ran. It probes a replay of every failing campaign (other tests must stand aside, the replay must start from the counterexample and reproduce it) and a rerun of each property's first violating fixed history or history (the printed `-t` command must run exactly that test). Eighteen fixed histories replay the framework's regressions and witness each state its laws are about; campaigns count those states. Maintainer decisions of 2026-11-03: a calibration skips a candidate whose clean trace fails; within one history a driver error decides first, then a law violation, then a missed constructed-history state; a generated property's determinism test also runs three generated histories. Seventeen framework mutants, including every survivor class from the three reviews, are rejected. The `clock` law (maintainer decision, 2026-11-06): a driver on `useOracleClock` whose clock fires more than its bound fails that history under law `bounded clock`, ranked as a driver error is; three fixed histories witness it, and a spin that always decides is rejected. Generated campaigns rarely combine a clock property with a spinning item, so the fixed histories carry that law. Limits: one rerun probe per property; the model relies on fast-check running a grammar's constructed histories first; `forwardCut`, `withinTurns`, `resolve` errors, and the gap report's observation hook have no law here; a run takes about two minutes. | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial keys-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; fixed histories require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner fixes which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: [#2349](https://github.com/TanStack/db/issues/2349) reproduces an optimistic nested sibling edit converting an untouched class/null-prototype leaf to a plain record, then restoring its shape at sync confirmation. The maintainer identified this as a bug and explicitly kept its repair outside #2346. The draft/revert owners must add generated sibling edits and pending/confirmed shape observations while preserving isolation. A draft also reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, and delete through handler parameter and mutation-alias access. All calls explicitly refetch; former `refetch`/`clearError` coordinates remain only as historical replay labels, preserving all 12 identity and 12 mutation-overlap executions. The ordinary [health scenarios](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.test.ts) observe exact public causes and settlement. A held refetch retains the current public error. A failed initial load, including a failed initial Collection application, remains a loading error for that sync run regardless of later fetch results. After readiness, a pending retry retains the prior failure reason; failure reports the later cause, and successful result application clears it. Query retry attempts and an older held success are crossed with newer failures, including reuse of one Error value in one clock tick. These scenarios supplement the oracle; they do not claim removed utility fields or error counts. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. Direct-write witnesses now distinguish an idle pending eager cache seed (success with no new freshness timestamp) from an in-flight pending request, and require update, delete, and upsert to reach both eager Collections through their shared physical Query, in both acquisition orders. They check both public Collections and the Query cache immediately after each write. A held older shared fetch also crosses B's release and reacquisition, then compares both public rows at refetch and load settlement; mixed select wrappers and three or more co-owners remain outside this witness. The load-lifecycle tests also restore an intermediate checkpoint that keeps an explicit subset refetch pending until a post-write request applies, and rejects a no-cancel mutant at the replacement-request assertion. . Two sync-metadata session witnesses fail on `30175be48`: a claim that inserts a row keeps applied non-owner metadata, and a claim during a held retention cleanup publishes the cleaned owner (see Sync metadata session) | -| Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. Generated histories cross both modes, attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart, with declared reach; replays pin the crash, fail-then-cleanup, and crash-before-attach traces. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph or an on-demand subset; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests; the property is not in the `oracle-config.ts` path-replay registry. Known unresolved cleanup/restart witness [#2334](https://github.com/TanStack/db/issues/2334): empty hydration seed; attach; ready; write `b=0`; cleanup; restart; write `b=0` again. Replaying the shared model on `162eebf39` fails in both modes. A diagnostic continuation that omits the disputed loading checkpoint and then marks ready still fails: public Collection rows contain `b=0`, while the attached observer retains no rows. The row clock advances 2→3 but the observer capture stays at 2. This is outside metadata-only container sharing and remains open under this hydration/lifecycle owner; do not claim general cleanup/restart freshness. The next witness must distinguish retained subscription rows from the observer reset, extend generated repeated-key histories, and calibrate the repair at a ready checkpoint. The existing comparison is not relaxed. | +| Observer hydration under terminal statuses | `packages/db/tests/live-query-observer-hydration-oracle.property.test.ts` | ARCHITECTURE.md law 16 and the glossary "crash": while a hydration seed is active, a Collection `error` or `crashed` shows in the snapshot, attached or detached, and is announced; an attached observer that sees the failure ends the seed, so cleanup then shows `cleaned-up` and live rows, not the stale failure or the seed. Decision (not a law-16 claim): `cleaned-up` and restart under an active seed keep the seed as `ready`, because cleanup is not a failure and the next sync run's readiness hands off. After the seed ends (readiness or a failure), cleanup and restart show the live Collection: `cleaned-up` with no rows, then every row the new sync run writes or reacquires, including a row equal to one shown before cleanup. That restart claim adds no rule: its authority is the header's law 2 (an observer whose seed ended behaves like one without a seed and shows the live Collection) with the glossary's "cleanup" (it ends the sync run; the Collection remains available for restart) and "crash" (cleanup discards all state, so a later start is a fresh start); the ARCHITECTURE sentence on the observer's subscription documents the mechanism, not the authority. The `live-query-observer.hydration-terminal` property (`oracleProperty`, in the path-replay registry) generates histories of up to 10 steps that cross both modes, an eager or an on-demand source (whose loader re-delivers the provider's rows, which survive cleanup), a seed of 0..3 rows or none (an observer without a DbClient, the reference case of law 2), attach order, live writes, `markReady()`, `fail()`, `crash()`, cleanup, and restart. Its 13 declared states are model states (a status while the seed is active, attached or detached; attach to a failure; cleanup after a failure ended the seed; restart under the seed; a row from before cleanup back after restart once the seed ended; the same through an unseeded granular observer's own on-demand subset demand). Eleven constructed histories enter them, so every fixed and random campaign reaches every state, and half of each grammar's histories start with a constructed history; each grammar prints its reach counts. Fixed histories replay the crash, fail-then-cleanup, and crash-before-attach traces and the two restart-rewrite traces in both modes and against both source kinds, and the restart-rewrite traces also without a seed. The check judges laws `view` (status, flags, claimed rows) and `announcement` (last-read snapshot, granular rebuilt rows). The migration from the hand-built campaign kept every mutant kill and every gap-report outcome on `src/live-query-observer.ts` (seed 42); the old prefixed histories ran up to 13 steps. Limits: rows under a failure status are not claimed; the source is a plain controlled Collection, not a live-query graph; on-demand loads settle at once, with no predicate, window, or pending load across cleanup; streamed client errors, handoff timing, and framework adapters stay with the focused observer and adapter tests. Closed cleanup/restart witness [#2334](https://github.com/TanStack/db/issues/2334) (also the random campaign's seed 1779366423, path 30:6:8:8:8:8): attach; ready; write `a=1`; cleanup; restart; write `a=1` again. The observer reset its rows at `cleaned-up`, but its Collection subscription kept them as its last publication (ARCHITECTURE "Cleanup, restart, and detached waiters"), so it dropped the equal rewrite as already shown; an on-demand restart's replay and sent-key filter dropped reacquired rows the same way. The observer's subscription now discards its published rows and sent keys at cleanup (`discardsRowsOnCleanup`). RED on `a17c8e468`: 19 of 38 tests fail with law `view`, rows [] where the model owes the row, also at a ready checkpoint when only ready checkpoints are judged. Mutants that drop the option, take the stale copy before the discard, discard only in granular mode, keep the sent keys, or keep the published rows each fail; an observer that keeps rows under `cleaned-up` fails at the cleanup checkpoint. The fix's production half is in `CollectionSubscription.handleCollectionCleanup`, so the oracle's owns name it (the gap report follows only `#function` entries into other files, and no Collection subscription owner subscribes with the option). Gap-report limit: on `@tanstack/oracles` b937148 the gap report refuses to run on `src/collection/subscription.ts`: its statement-deletion mutant rewrites the constructor's `super()` as `m(n) \|\| super()`, which breaks a derived class with parameter properties (`ReferenceError: Must call super constructor`), so every oracle that builds a subscription fails with no mutant active. Until TanStack/oracles fixes it, the hydration oracle's kills there are shown by direct runs: the authored mutants above and operator mutant M92 (`if-false` on the option) fail it with law `view`; M91 (`if-true`, every subscriber discards) and M88 (`if-true` on `!reload`) survive it, as they change only direct subscribers and reloads, which this grammar does not draw. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | -| Query DB ordered refetch | `packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts` | Issue #2136: after preload, and after `utils.refetch()` or a Query invalidation settles, an on-demand Query Collection behind a LEFT-joined, ordered, limited live query must show the window over the current backend rows. The design under test: query-db reports `invalidateLoadedSubsets()` when an ordered page ends earlier in the order than before, including when its keys stay the same, or when its keys change and it has a later page. Each ordered loader runs its bounded prefix repair. A persisting optimistic update or delete can hide the visible order change that otherwise starts repair. The `query-db.refetch-held-page-end` property constructs that same-key earlier-end overlap through the real QueryClient and core receiver, with 24 fixed and 24 random histories (reach minimum 24 each), numeric finite cursor pages, static/function keys, one/two order terms, limits 1–3, offsets 0–1, varied row IDs, and explicit refetch/Query invalidation. Each history requires the same-key earlier end on the deleted held boundary, a fresh gap between the two answers, a delivered report, and a subsequent real core repair acquisition. Forty-eight structural histories cross widths/offsets with delete/update/unrelated-delete/no hold and earlier/later ends. Six delete-gap width/offset states each require structural reach. Label-only edits are negative report controls. The [nine-item review](oracle-reviews/2026-11-02-query-refetch-nine-item-review.md) records the strengthened classifier, experiments, portable commands, and finite limits. The exact failing CI history replays the shared model and comparison. Both the original implementation and the restored equal-key guard fail the public source-prefix comparison after release. See [the held-page-end review](oracle-reviews/2026-11-02-query-refetch-held-page-end.md) for exact source bindings, evidence, and limits. Its model is a pure top-k of marked backend rows. A fixed and a random campaign generate rounds of marker flips (which serve continuation pages), backend changes while the provider lags, a second live query (`observe`) whose own demand can load a page's new row first, optional Query invalidation with further changes, and a refetch, across one or two order terms, a numeric or locale-collated string order, limits 1–3, offsets 0–1, and a static or function Query key. With an unchanged backend, a repeated refetch must serve no more rows, start at most one first-page request, and report nothing. A refetch or invalidation must never start a new full-source request. A coverage law runs at the same checkpoints: every backend row from the start of the order through the window's last row (every row, when the window is short) must be in the source Collection with its current values, so a latent gap fails before a marker exposes it; a named replay of the issue trace with the moved row unmarked passes the window law and fails only the coverage law when Query DB never reports. The grammar can hold an optimistic update across a refetch, with the checkpoint moved to its release, and a premise-directed campaign under a hold (other rows observed, a row moved ahead of the page's end, a new row further ahead, a write to the last-sorting observed row) requires a report during a hold in every case and fails a skip that reads rows held back behind the transaction; the general campaigns did not. The general grammar holds optimistic deletes as well as updates. The generated campaigns declare reach (minimum 3) for a lagging refetch after a continuation, one during a hold, and a refetch that reported while the window was full, where the loader decides between ignoring the report and repairing. A named replay of a refetch during a held delete left the window short after release, also with this PR's report and overlap changes reverted, until the fix that resumes a continuation after a synchronous answer; no window is checked during a hold, where #2148 and #2169 remain open. Reports (`loadedSubsets:invalidated`) are bounded: a step reports at most once, and only when an ordered reanswer changes key membership or moves its end earlier, so unrelated value changes do not justify reports; named replays require no report when a one-page window, or a window's last page, only loses a row, and a sync-boundary replay requires a report when a page ending on a row without an order value has its end move earlier (on the earlier gate, which read that page as empty, it reported nothing), and another requires a report when the provider returns a one-page window's rows in reverse order (the earlier gate took the last array row as the page's end). When a page's end moves earlier, Query DB reports. A loader whose window lies inside its own first page skips the repair only while its page read past the window or it read the tie of the current end; after an insert into a top-N list moves the end, a named replay requires the loader to prove the window again with bounded reads, no full-source read, and an unchanged Query count. A filtered one-page window whose page end moves past an unseen row must still repair (found by the random campaign; a loader that always skips fails it, the f9 tie replay, and the ordered-work oracle's signalling replays). The skip also refuses while the source holds accepted sync rows back: a replay refetches while an optimistic update persists, so the report arrives before the refetched rows are visible, and a skip that reads those older rows fails it. The ordered loader state oracle checks that a prefix read issued before an ordering invalidation, which settles after the bounded repair, does not retire the repair's prefix; the repair-first order failed before that fix. A `loadSubset` call that acquires a released, invalidated page can report, because Query then refetches that page on its own. A failed repair shows only the last window or the right one. Named replays cover the issue trace after a refetch and after an invalidation, with a page-capping provider, with another live query loading the page's new row first, with an empty later page, with a page whose end moves earlier while a tie request holds its old last row, a second refetch after an invalidation, a repair failure, and a provider that honors neither cursor nor offset. Never signalling, the old overlap-to-full-source rule, ignoring empty later pages, reporting on any page change, removing the repair loop guard, and reporting once per page each fail it by assertion. A replay makes a page's application fail and checks the window shows its last result or the right one, then the next refetch restores it; recording a page before its rows are accepted cannot be distinguished, because the failed attempt reports on its own. Open: the report laws bound reports but do not require each one; a page whose end moves earlier reports even with unique order values, where no row can be missing; it does not check whether a repair waits for in-flight work; the generated campaigns use an uncapped cursor provider only (an offset-only provider and a page-capping provider each hit a loader defect that reproduces on main: #2182, #2183); the generated campaigns do not check the window after a marker flip (a named replay checks the trace where the loader stopped refilling after a joined-side removal, which the repair-retirement fix below closed); a failed repair still recovers through the loader's full-source retry; interval, focus, and reconnect refetches share the invalidation path but are not driven; NaN order values have no executable witness; overlapping `refetch: true` acquisitions on one Query hash are counted, so the first to finish does not clear the loop guard for the other, but no witness separates that from a plain set; a Query refetch that joins a running `refetch: true` acquisition on the same hash does not report, and no witness reaches it (#2185); follower tabs of a multi-tab persisted collection never receive the report (#2174). The offset-only and page-capping loader defects have `it.fails` witnesses in the oracle. A finished bounded repair used to discard its boundary when its retirement made Query DB remove a row the loader had sent; the loader then restarted paging with a smaller first page that could not retire the repair's prefix, so every later refetch read both. A named replay found by the random campaign (at four times the run count) and an ordered loader state witness fail on that, and the fix also closed the joined-side refill defect. At four times the run count, the random campaign found a short window that stopped paging before the last row of the order: a report's repair released continuation Queries, and Query DB answered their reacquisition synchronously from its cache, so the graph turn inside each request asked for more while the loader was still requesting and nothing resumed the staged continuation. Only the coverage law failed. A named replay and an ordered loader state witness (a synchronous answer whose writes the graph already processed must continue the chain) fail on that, and pass with the fix. The loader side of the report also has a second owner: the ordered-work oracle's `refetch-signal` provider re-runs each served request alone and reports when any answer changed, across that oracle's generated plans, and a signalling refetch must start no full-source request unless a bounded page in that step ended on a null or NaN rank, which takes the documented full-source fallback (a random seed found that case). Never signalling and the old overlap-to-full-source rule each fail it. It excludes a peer live query, which can keep a short window after a refetch (#2155). The persisted-collection oracle checks that a source's report reaches the Collection through the managed cache-generation wrapper and that a retired source session's report is dropped. Remaining work for this class is tracked in #2186. | -| Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md), [eager ordered-window repair review](oracle-reviews/perf-ordered-window-repair.md), [reversed index reads](https://github.com/TanStack/db/blob/main/packages/db/tests/reverse-index-oracle.property.test.ts) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. The ordered-work oracle also checks an eager indexed window over generated upserts and deletes: the rows equal the filtered, sorted model, and each change delivers a bounded number of source rows; an unbounded or always-full repair read fails it. Its filter dimension adds a function filter and a LEFT-joined filter, and a campaign fixed to the joined premise checks that a window refilled past a distant row the query already received holds the first eligible rows. Joined filters also run as anti-joins, change eligibility through the marker or the root row's join key, and drive an Effect beside the Collection. A full-join filter keeps every root row and adds a null-ranked rootless row for each marker no root row matches; on main a bounded root prefix showed markers whose root rows were not yet loaded as unmatched rows, and restoring the old inner-or-right full-source rule fails eight tests. Null placement is a generated dimension, with the index built to match, and a query that ignores it fails six tests. A full-join campaign has few or no filler rows, a matched null-ranked root row and at least one orphan marker in every case, so its windows reach the null group where rootless rows and null-ranked root rows tie; a reconstruction check samples exactly the cases its fixed-seed run executes and requires such windows (19 rootless and 7 mixed null-group windows of 40 at one times the run count), and the Effect runs beside the Collection. Full joins read their full source, so only the rows law covers them; paging them under the old join rule fails the window law. Pinned cases cover a null-ranked root row past a prefix that ends in the null group. Paging a nulls-last full join passed this oracle but was not adopted: it cost more per delete when the window reached the null group, which no work law here measures, and #2130 tracks full-join paging. Joined filters can match a row through several markers, and the model keeps one result row per matched pair; a model that collapses them fails. The eager harness also runs an on-demand source whose provider follows the LoadSubsetOptions contract (offset only without a cursor, the applied receipt returned). Its test provider combines a sync adapter and its provider (glossary model alignment rule 2); a held request is a physical acquisition whose provider answer is delayed, a served range is the prefix and boundary one answer establishes (a model abstraction), and the peer is a second live-query Collection. The provider sorts by the requested order and keeps its own ledger of sent rows. A forwarding provider sends live changes only for rows it sent or rows that match a served subset, and its served rows obey the work bound; a loader that asks for 100 extra rows fails it. A refetching provider, like query-db-collection, sends nothing until a refetch. The refetch keeps every served ordered range whole, as the LoadSubset contract requires: it sends every row from the start of the order through the last order value a limited page returned, and rows in no range leave; the rows law over the model applies once no change is pending. After every step on either provider, the Collection must hold exactly the rows in the provider's ledger, and the query and the Effect must show their window over those rows; a mutant that drops marker removals fails this law at steps 0 and 1 of the fixed refetching campaigns, earlier than the model's rows law, and in one more campaign. A holding provider answers each load only at an `answer` step, from the rows at that moment, so live changes arrive while loads are in flight. While a load is in flight, the query and its peer either keep the window they showed at the previous checkpoint or show distinct ledger rows that pass the query, in its order; the Effect is not checked then. That is weaker than the architecture's barrier rule, under which a repair or window move keeps the last public snapshot until it settles: a barrier law fails today on on-demand joined plans, where a visible-delete repair and a held `setWindow` publish before they settle (#2131; the law and traces are kept for that fix). The provider records a request as served only once it answers it, and an abort (or an adapter unload) of a held request follows `LoadSubsetOptions.signal`: a cooperative provider installs none of its rows and its load fulfills at once, while a provider that cannot cancel in-flight work (`late` cancellation, generated) answers it anyway and its load fulfills after those rows become visible. A `release-peer` step cleans up the peer while its loads are held, which aborts them; each request counts once although its acquisition release both aborts and unloads it. The fixed held campaign reaches 5 aborted held requests and 1 late answer, and its reconstruction check requires at least 3 cooperative cancellations and 1 late answer. A held load rejects when its applied receipt rejects, and the step then fails, because no signal or cleanup rejects a receipt in these histories; that branch is unwitnessed. The held campaign pins a forwarding provider, so `release-peer` never reaches a refetching provider; that branch is unwitnessed. A request whose signal is already aborted when the provider receives it is never reached; that branch is unwitnessed. The held campaign uses a forwarding provider and an indexed source filter or a joined filter, deletes three visible rows and then upserts an eligible row with an id no other command uses; its reconstruction check runs in the same test as the fixed-seed campaign and reads its per-case counts: 35 of 40 cases issue a held request, and every one of them changes rows while it is in flight. Loading must reach a fixed point: past a request budget the provider rejects and records it, and the initial load, every step and the end of each history assert the record is clear (a loader that re-requests a prefix it already holds can loop without reaching the provider, which this cannot see). A bounded step makes at most four provider requests, and each consumer's initial proven-prefix load is bounded separately, in total transfers (a repeated request for the same prefix counts) and in distinct rows (at most the source's size); a loader that asks for 100 extra rows on every page fails that bound and the initial-load work law. Loader mutants that never resume after a boundary request, or that ignore a load request while another load is pending, fail it; a mutant that drops a window move blocked behind a pending load still survives, even with the settlement check on held moves; whether it is equivalent is open. Open (#2148): on an on-demand source with a joined or anti-joined filter, an optimistic delete of the visible row leaves the window short until the delete persists, because the next row is never loaded. On an on-demand source with a source filter, and on an eager source with any filter, the same delete refills the window correctly; these are the contrast cases. Owner: this oracle. Needed witness: the optimistic-delete dimension (kept as a patch for #2148) in the grammar, plus a pinned replay of the anti-join trace. A refetch that re-runs each page alone, as query-db-collection does today, leaves a row that moved between two pages unloaded; random seeds found that trace, a named replay fails on it, and a query-db receiving witness reproduces it (#2136 tracks the provider fix). An Electric-style provider, which skips updates to rows it never sent, is not modeled; a mocked-stream witness shows a row moving into a served ordered range never arriving under the default replica mode (#2138). At one times the run count, the campaigns reach 87 refetch steps: 39 change the window, 6 remove a visible row, and 13 lead to a further provider request. A random seed found a refetch that empties a joined window, after which the loader refills it over several page requests and publishes `[]`, one row, then the final rows; a named replay keeps that trace for #2131. No incorrect final window has appeared on that path, and no loader mutant tried so far is caught only by it; the work law does not cover it. For an on-demand joined or anti-joined filter, the initial load must not request the whole source when the first window's worth of source rows in query order all pass the join and no boundary value is null or NaN; 35 executed cases meet that premise, and a compiler that reads the whole source for every joined filter fails eight tests on this law alone. The rows served by that initial load stay within the prefix, its boundary ties and a refill, for each consumer. Later steps of those plans have no work bound. An unindexed join filter over it left the window empty on main, until the plan counted the rows it still needs; restoring the old guard fails the campaigns and a pinned replay. A step that changes the visible rows must publish, and its subscriber starts with every visible key, so a removal-only change reaches it; mutants that drop update-only or removal-only publications fail. A step may publish one intermediate window per provider request it made, and only its last publication must show the final window, because a repair or refill can publish after each round trip (#2131); the content of those intermediate windows is unchecked. The model keeps its own copy of the markers, and full-join histories can insert the root rows of orphan markers. A premise-directed campaign inserts a fresh row and then changes an initial row's eligibility alone, so the row leaves the window only through the join; it and a named replay fail when a window row past the loader's settled boundary counts toward a full window. One case in four adds a second live query over the same sources with a larger window, and its rows law holds at every checkpoint; the work laws do not apply to those cases. #2155: a second live-query Collection over a refetching on-demand provider kept a short joined window after a refetch removed a tied row (the peer showed ranks `[0, 0]` where the model shows `[0, 0, 100]`). The loader fix that resumes a staged continuation after a synchronous answer the graph already processed closed that trace. The pinned replay of that indexed trace now expects the right window. Still open: an unindexed peer over a refetching provider keeps a short joined window (the peer shows `[100]` where the model shows `[100, 101]`), which also fails without that fix; each campaign therefore still excludes the peer and refetching pairing, as its last map. Owner: this oracle. Needed witness: a pinned replay of the unindexed trace flipped to pass, and the pairing restored to the grammar. Limits: Effects are checked for rows after each step, not for publication counts or positions. Histories move the first query's window with `setWindow` (150 moves at one times the run count, 56 on demand), and its rows law and ledger law then follow the moved window; the Effect and the peer keep theirs. On a holding provider every window-move promise must have fulfilled after the final drain. A refetching provider models query-db's unload: it drops the unloaded request and removes rows that no remaining request's last answer holds. The per-step work law applies only to forwarding, immediate, single-consumer cases: it skips refetching, holding and peer cases and move steps; a two-term indexed move scanned the source four times, which no law here bounds. Multi-term joined plans stay outside this harness, on both paths; on the on-demand path, the provider's transfer is bounded only for indexed single-term source-predicate plans. Joined windows compare order values, so a wrong row tied with the expected one passes; a window row tied with the boundary counts as proven before its tie request settles, which no witness exercises. A boundary that cannot advance past a window row would repeat one page per new key; reading the boundary's range locally should always reach that row, but no witness shows it. Joined ties are compared as any valid first rows; whether source-filter ties may be fixed to the source key is open. The same grammar includes NaN and null ranks and a second-order term, and bounds both rows read and rows delivered, including the initial load and index refills; a scan law limits each change to one pass over the source. A pinned case checks that an indexed descending window places nulls first, and a lifecycle witness checks that an eager full-source window is ready with its rows at creation when its source answers synchronously. The initial fallback cells check indexed and unindexed locale strings with distinct and tied values, plus a null boundary after a warm ordered request. The warm null-boundary case crosses preload-started and constructor-started live-query sync runs: the former completes at the synchronous cut, while the latter resumes deferred on-demand acquisition asynchronously and publishes after settlement. They require an unfiltered full-source request, no public row at fallback entry, and the expected row and status at the immediate readiness check. A Promise fallback stays loading at that checkpoint. A React receiving witness checks an eager locale-string `findOne()` at the first layout commit. A Query DB receiving witness checks warm cached subsets: both raw adapter calls return literal `true` with the source rows visible at each return, and the live query is immediately ready without a fetch. The same fallback cells observe subscriber changes from before the first source write: literal-true and Promise fallbacks each publish one nonempty final-window insert after the fallback settles, and the callback sees the complete row. Promise cells show no nonempty event at the immediate cut; a duplicate-event fault fails all eight cells. Other adapters and a real on-demand React first commit remain unwitnessed. Full-source work after initial publication and repair remain outside this synchronous guarantee. A separate post-publication oracle crosses marker deletion and retargeting with literal, Promise, and held-applied-receipt fallback responses. It also settles the child demand before holding the receipt, so the public live-query loading flag isolates the root fallback. The model checks final rows and net events, while event replay checks every callback snapshot; a retained or empty intermediate window is allowed. A loader fault that ignores the pending receipt fails at the held loading-flag check. Two literal-true cleanup-failure histories require that the replacement still publishes and the adapter error reaches the public query status without making the unrelated marker commit throw; the provider's applied receipts are recorded and compared outside provider callbacks. The Promise case checks eventual coherence on an asynchronous callback path, without claiming a separate timing cut. Four held-Promise histories apply fallback writes, change another join marker before provider settlement, and compare each callback and the final winner; an injected stale extra publication fails the event comparison. Other marker actions and overlapping root-source changes remain open; the provider has no later write or rejection, so this schedule does not establish when the loader claims full-source authority. The reversed-index oracle checks every reversed read's order and filter work for both null placements. The retirement-invalidation lane (`ordered-loader.retirement-invalidation`) composes a constructive invalidation prefix, up to four settled answers that let the repair chain complete, and generated command suffixes with index availability, limit 1/2, joined deferral, needed rows, release errors, reentrant truncate and disposal, reentrant ordinary passes, retry timers, offset 0/1, and full-source plans. Its declared `completed-repair-release` reach counts only an invalidating release observed while a successful repair-chain answer settles and issues no further chain step; an instrumented run matched the production retirement path in 66 of 72 such histories, and the rest owed nothing more after a late disposal or full coverage; an ablation that invalidates only on other releases passes the older `release-invalidation` reach and fails this one. Only an on-demand source answers a release with an adapter unload, so neither this lane nor the broad `ordered-loader.request-authority` arbitrary pairs `releaseInvalidates` with a synchronous repair answer, which reads an eager source whose releases bypass the adapter (`CollectionSyncManager.unloadSubset`). The broad arbitrary weights `releaseInvalidates` 5:1 like the other release reactions, because an invalidating release leaves the quiet report laws. The directed lane also places the invalidation after the callback's disposal, reentrant pass, and truncate (`late-release-invalidation`). Broad overlap states require 20 histories each, and the rarer full-source-plan overlap requires 5. An invalidating unload also writes graph input, so the driver's drain runs one more loader pass before it judges progress, as the live-query Collection and Effect graph loops do; without that pass, a repair that finishes inside a loader pass after joined work clears was misjudged as owing no further read. Its independent request ledger records issue and invalidation events on one clock. It requires an authoritative start after the callback's invalidation for a live finite repair after retry/drain; complete full-source coverage absorbs invalidation, cursor reset transfers pre-reset work to replay, disposal ends ownership, and unanswered authoritative work retains its existing no-timeout limit. Each normal broad campaign measures callback overlap with release error, truncate, disposal, pass reentry, offset one, timer-enabled scenarios, full-source plans, and multiple successor chains. A separate `ordered-loader.retirement-receiver` property runs real live-query Collection and Effect consumers, and a live-query Collection whose `setWindow` to the other offset starts in the same turn as the ordering write. It varies indexed/unindexed sources, limit 1/2, offset 0/1, literal/Promise adapter answers, a held newer replacement answer, and the unload's change: moving the repaired prefix, deleting its rows before the boundary, deleting the boundary row, or deleting a prefix row that ties with it, each of which owes a replacement, or evicting a sent row past the repair's boundary, including a row that ties with the boundary but lies outside the prefix, which keeps that boundary and starts no read. A tie row sorts before or after the boundary row by key, as live-query Collections order ties. A same-turn window move may repair either window's prefix, so that kind keeps only the move and row 1's eviction; prefix deletes and ties run on the other kinds. Each consumer has separate declared adapter-unload and held-answer reach, and delete, boundary-row delete, tie delete, tie eviction, and eviction unloads have their own reach; each grammar runs 600 histories. A loader that skipped every retirement-time delete fails the delete histories at the progress relation, one that treated every sent-row delete as new information fails the eviction histories, one that treated every boundary tie as eviction fails the boundary-row and tie deletes, one that treated every boundary tie as new information fails the tie evictions, and one that kept only the boundary row's key fails the tie deletes. The loader keeps the keys of the settled prefix rows that tie with its boundary row. The focused `keeps a repair's boundary when its retirement removes a row the loader sent` test remains as a supplemental example. An instrumented run placed every invalidating receiver unload inside retirement (200 of 200 per campaign). Following Atomic window publication, the window move stays pending while the replacement is held and settles with its chosen window visible; a mutant whose subset loads stop joining the active window operation fails only those move comparisons. Held-answer reach is recorded only after the provider answer is blocked and the prior-window and no-publication comparisons pass. The provider moves its repaired prefix behind a still-uninstalled row during unload and supplies no later external graph input. The shared progress relation checks that an authoritative start follows the invalidation and precedes the first replacement publication. Independent full-provider sorting checks final rows; held answers retain the previous complete public window and publish no callbacks, and completion publishes one complete replacement. Fixed replay witnesses force the held-answer premise for each consumer. An executed public-method call probe observed reentrant `loadMore` inside every sampled unload for both consumers, with no production-state reads. Original debt-clearing and deliberately delayed-one-pass mutants fail the progress/publication judgments; start observations before the invalidation or at publication fail calibration. This evidence does not require recursive synchronous dispatch: deferred replacement remains legal while the public gate holds. The replay-continuation lane (`ordered-loader.replay-continuation`) follows ARCHITECTURE.md, Ordered requests/continuation/recovery: a pre-reset request belongs to truncate replay and cannot revive its discarded cursor; a graph continuation survives bookkeeping until a graph pass consumes it or cursor reset discards it. `continuationOwed` is model-only classification provenance, not product completion state. The unchanged handoff/reset grammar and exact shared-model replay reject consuming that provenance during replay bookkeeping. This is a harness correction; it supplies no production resource-leak claim. Its `deferred-refill` state is reached only when a synchronous repair answer meets a release-triggered truncate (6 of 96 inputs). An eager source's release never reaches the adapter (`CollectionSubscription.createSubsetAcquisitionRecord`), so production cannot produce that pairing, and the lane calibrates continuation provenance without a production-reach claim. The broad request-authority arbitrary pairs synchronous repair answers with release faults, truncates, disposals, and reentrant passes in the same over-approximating way; those histories pass and support no production-reach claim either. The random handoff campaign runs 400 histories so that state stays above its minimum. Limits: the real receiver uses distinct finite numeric ranks, ascending root order, one source and consumer, one qualifying callback invalidation, no failure/truncate/disposal inside the real unload callback, one window move per history and no failed replacement under a move, and no provider-specific network. Its on-demand source settles every repair answer asynchronously in the loader, even a literal adapter answer; the loader's synchronous repair settlement needs an eager source, which has no adapter unload to invalidate. The controlled grammar measures the combined lifecycle cases; it does not transfer all such combinations across the public receiver boundary. No claim about obsolete later cursor-demand retirement (#2223) or arbitrary callback composition follows. Existing run budgets remain unchanged. Random samples do not establish bounded exhaustiveness. The controlled and receiving grammars have distinct registered replay properties, so each seed and shrink path selects only its own driver. The no-leak calibration supplies a retained-lease observation to the same request-ledger comparison after an unmodified run of the same covering-repair history; it reads no private member names. | +| Query DB ordered refetch | `packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts` | Issue #2136: after preload, and after `utils.refetch()` or a Query invalidation settles, an on-demand Query Collection behind a LEFT-joined, ordered, limited live query must show the window over the current backend rows. The design under test: query-db reports `invalidateLoadedSubsets()` when an ordered page ends earlier in the order than before, including when its keys stay the same, or when its keys change and it has a later page. Each ordered loader runs its bounded prefix repair. A persisting optimistic update or delete can hide the visible order change that otherwise starts repair. The `query-db.refetch-held-page-end` property constructs that same-key earlier-end overlap through the real QueryClient and core receiver, with 24 fixed and 24 random histories (reach minimum 24 each), numeric finite cursor pages, static/function keys, one/two order terms, limits 1–3, offsets 0–1, varied row IDs, and explicit refetch/Query invalidation. Each history requires the same-key earlier end on the deleted held boundary, a fresh gap between the two answers, a delivered report, and a subsequent real core repair acquisition. Forty-eight structural histories cross widths/offsets with delete/update/unrelated-delete/no hold and earlier/later ends. Six delete-gap width/offset states each require structural reach. Label-only edits are negative report controls. The [nine-item review](oracle-reviews/2026-11-02-query-refetch-nine-item-review.md) records the strengthened classifier, experiments, portable commands, and finite limits. The exact failing CI history replays the shared model and comparison. Both the original implementation and the restored equal-key guard fail the public source-prefix comparison after release. See [the held-page-end review](oracle-reviews/2026-11-02-query-refetch-held-page-end.md) for exact source bindings, evidence, and limits. Its model is a pure top-k of marked backend rows. A fixed and a random campaign generate rounds of marker flips (which serve continuation pages), backend changes while the provider lags, a second live query (`observe`) whose own demand can load a page's new row first, optional Query invalidation with further changes, and a refetch, across one or two order terms, a numeric or locale-collated string order, limits 1–3, offsets 0–1, and a static or function Query key. With an unchanged backend, a repeated refetch must serve no more rows, start at most one first-page request, and report nothing. A refetch or invalidation must never start a new full-source request. A coverage law runs at the same checkpoints: every backend row from the start of the order through the window's last row (every row, when the window is short) must be in the source Collection with its current values, so a latent gap fails before a marker exposes it; a named fixed history of the issue trace with the moved row unmarked passes the window law and fails only the coverage law when Query DB never reports. The grammar can hold an optimistic update across a refetch, with the checkpoint moved to its release, and a premise-directed campaign under a hold (other rows observed, a row moved ahead of the page's end, a new row further ahead, a write to the last-sorting observed row) requires a report during a hold in every case and fails a skip that reads rows held back behind the transaction; the general campaigns did not. The general grammar holds optimistic deletes as well as updates. The generated campaigns declare reach (minimum 3) for a lagging refetch after a continuation, one during a hold, and a refetch that reported while the window was full, where the loader decides between ignoring the report and repairing. A named fixed history of a refetch during a held delete left the window short after release, also with this PR's report and overlap changes reverted, until the fix that resumes a continuation after a synchronous answer; no window is checked during a hold, where #2148 and #2169 remain open. Reports (`loadedSubsets:invalidated`) are bounded: a step reports at most once, and only when an ordered reanswer changes key membership or moves its end earlier, so unrelated value changes do not justify reports; named fixed histories require no report when a one-page window, or a window's last page, only loses a row, and a sync-boundary replay requires a report when a page ending on a row without an order value has its end move earlier (on the earlier gate, which read that page as empty, it reported nothing), and another requires a report when the provider returns a one-page window's rows in reverse order (the earlier gate took the last array row as the page's end). When a page's end moves earlier, Query DB reports. A loader whose window lies inside its own first page skips the repair only while its page read past the window or it read the tie of the current end; after an insert into a top-N list moves the end, a named fixed history requires the loader to prove the window again with bounded reads, no full-source read, and an unchanged Query count. A filtered one-page window whose page end moves past an unseen row must still repair (found by the random campaign; a loader that always skips fails it, the f9 tie replay, and the ordered-work oracle's signalling replays). The skip also refuses while the source holds accepted sync rows back: a replay refetches while an optimistic update persists, so the report arrives before the refetched rows are visible, and a skip that reads those older rows fails it. The ordered loader state oracle checks that a prefix read issued before an ordering invalidation, which settles after the bounded repair, does not retire the repair's prefix; the repair-first order failed before that fix. A `loadSubset` call that acquires a released, invalidated page can report, because Query then refetches that page on its own. A failed repair shows only the last window or the right one. Named replays cover the issue trace after a refetch and after an invalidation, with a page-capping provider, with another live query loading the page's new row first, with an empty later page, with a page whose end moves earlier while a tie request holds its old last row, a second refetch after an invalidation, a repair failure, and a provider that honors neither cursor nor offset. Never signalling, the old overlap-to-full-source rule, ignoring empty later pages, reporting on any page change, removing the repair loop guard, and reporting once per page each fail it by assertion. A replay makes a page's application fail and checks the window shows its last result or the right one, then the next refetch restores it; recording a page before its rows are accepted cannot be distinguished, because the failed attempt reports on its own. Open: the report laws bound reports but do not require each one; a page whose end moves earlier reports even with unique order values, where no row can be missing; it does not check whether a repair waits for in-flight work; the generated campaigns use an uncapped cursor provider only (an offset-only provider and a page-capping provider each hit a loader defect that reproduces on main: #2182, #2183); the generated campaigns do not check the window after a marker flip (a named fixed history checks the trace where the loader stopped refilling after a joined-side removal, which the repair-retirement fix below closed); a failed repair still recovers through the loader's full-source retry; interval, focus, and reconnect refetches share the invalidation path but are not driven; NaN order values have no executable witness; overlapping `refetch: true` acquisitions on one Query hash are counted, so the first to finish does not clear the loop guard for the other, but no witness separates that from a plain set; a Query refetch that joins a running `refetch: true` acquisition on the same hash does not report, and no witness reaches it (#2185); follower tabs of a multi-tab persisted collection never receive the report (#2174). The offset-only and page-capping loader defects have `it.fails` witnesses in the oracle. A finished bounded repair used to discard its boundary when its retirement made Query DB remove a row the loader had sent; the loader then restarted paging with a smaller first page that could not retire the repair's prefix, so every later refetch read both. A named fixed history found by the random campaign (at four times the run count) and an ordered loader state witness fail on that, and the fix also closed the joined-side refill defect. At four times the run count, the random campaign found a short window that stopped paging before the last row of the order: a report's repair released continuation Queries, and Query DB answered their reacquisition synchronously from its cache, so the graph turn inside each request asked for more while the loader was still requesting and nothing resumed the staged continuation. Only the coverage law failed. A named fixed history and an ordered loader state witness (a synchronous answer whose writes the graph already processed must continue the chain) fail on that, and pass with the fix. The loader side of the report also has a second owner: the ordered-work oracle's `refetch-signal` provider re-runs each served request alone and reports when any answer changed, across that oracle's generated plans, and a signalling refetch must start no full-source request unless a bounded page in that step ended on a null or NaN rank, which takes the documented full-source fallback (a random seed found that case). Never signalling and the old overlap-to-full-source rule each fail it. It excludes a peer live query, which can keep a short window after a refetch (#2155). The persisted-collection oracle checks that a source's report reaches the Collection through the managed cache-generation wrapper and that a retired source session's report is dropped. Remaining work for this class is tracked in #2186. | +| Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts), [ordered loader state](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-source-loader-state-oracle.test.ts), [graph scheduler](https://github.com/TanStack/db/blob/main/packages/db/tests/query/scheduler.test.ts), [issue #1880 ordered-repair review](oracle-reviews/issue-1880-ordered-repair.md), [issue #1882 custom local collation review](oracle-reviews/issue-1882-custom-local-collation.md), [issue #1898 relation-filter review](oracle-reviews/issue-1898-relation-filter.md), [PR #1909 external review](oracle-reviews/pr-1909-external-review.md), [eager ordered-window repair review](oracle-reviews/perf-ordered-window-repair.md), [reversed index reads](https://github.com/TanStack/db/blob/main/packages/db/tests/reverse-index-oracle.property.test.ts) | Complete finite provider results, inherited locale and bounded/unbounded custom collation with exact request options, query-level inheritance across source aliases, real lexical/numeric disagreement, custom comparator ties, scan/index paths, pending windows, ties/nulls, lease ownership, Effect callback gates, including an indexed source update during Effect startup, and documented repair timing. Direct LEFT-joined filters cover local finite prefixes, pending child demand, child removal, and anti-join publication with custom full-source and unindexed fallback loading; unrelated include demand cannot stall the root continuation, and an empty joined replay wakes held Effect callbacks; remote relation hints remain outside this owner. Graph scheduler checks coalescing, dependency order, synchronous loader input, and repeated-alias failure ownership. The ordered-work oracle checks that a synchronous root refill reaches D2 before joined publication; a mutant that omitted the post-loader graph step failed at the second child demand. Custom collation proves local full-source acquisition only; it does not establish provider cursor capability or backend collation fidelity. Sibling-source input during a synchronous ordered continuation returns to graph work before another ordered acquisition. Request completion is not proof of unrequested source extent. A window move during existing joined demand waits for its root continuation, including an independently held root request; current demand failure rejects the move, while retirement and an obsolete rejection do not. A bounded ordered repair resumes its refill after joined demand settles. The ordered-source-loader state test checks that an explicit failed-request retry blocked by joined demand retains its generation until it can issue a request. Multiple simultaneously pending joined plans during a window move and a public-query failed-retry overlap still need dedicated witnesses. The ordered-work oracle also checks an eager indexed window over generated upserts and deletes: the rows equal the filtered, sorted model, and each change delivers a bounded number of source rows; an unbounded or always-full repair read fails it. Its filter dimension adds a function filter and a LEFT-joined filter, and a campaign fixed to the joined premise checks that a window refilled past a distant row the query already received holds the first eligible rows. Joined filters also run as anti-joins, change eligibility through the marker or the root row's join key, and drive an Effect beside the Collection. A full-join filter keeps every root row and adds a null-ranked rootless row for each marker no root row matches; on main a bounded root prefix showed markers whose root rows were not yet loaded as unmatched rows, and restoring the old inner-or-right full-source rule fails eight tests. Null placement is a generated dimension, with the index built to match, and a query that ignores it fails six tests. A full-join campaign has few or no filler rows, a matched null-ranked root row and at least one orphan marker in every case, so its windows reach the null group where rootless rows and null-ranked root rows tie; a reconstruction check samples exactly the cases its fixed-seed run executes and requires such windows (19 rootless and 7 mixed null-group windows of 40 at one times the run count), and the Effect runs beside the Collection. Full joins read their full source, so only the rows law covers them; paging them under the old join rule fails the window law. Fixed cases cover a null-ranked root row past a prefix that ends in the null group. Paging a nulls-last full join passed this oracle but was not adopted: it cost more per delete when the window reached the null group, which no work law here measures, and #2130 tracks full-join paging. Joined filters can match a row through several markers, and the model keeps one result row per matched pair; a model that collapses them fails. The eager harness also runs an on-demand source whose provider follows the LoadSubsetOptions contract (offset only without a cursor, the applied receipt returned). Its test provider combines a sync adapter and its provider (glossary model alignment rule 2); a held request is a physical acquisition whose provider answer is delayed, a served range is the prefix and boundary one answer establishes (a model abstraction), and the peer is a second live-query Collection. The provider sorts by the requested order and keeps its own ledger of sent rows. A forwarding provider sends live changes only for rows it sent or rows that match a served subset, and its served rows obey the work bound; a loader that asks for 100 extra rows fails it. A refetching provider, like query-db-collection, sends nothing until a refetch. The refetch keeps every served ordered range whole, as the LoadSubset contract requires: it sends every row from the start of the order through the last order value a limited page returned, and rows in no range leave; the rows law over the model applies once no change is pending. After every step on either provider, the Collection must hold exactly the rows in the provider's ledger, and the query and the Effect must show their window over those rows; a mutant that drops marker removals fails this law at steps 0 and 1 of the fixed refetching campaigns, earlier than the model's rows law, and in one more campaign. A holding provider answers each load only at an `answer` step, from the rows at that moment, so live changes arrive while loads are in flight. While a load is in flight, the query and its peer either keep the window they showed at the previous checkpoint or show distinct ledger rows that pass the query, in its order; the Effect is not checked then. That is weaker than the architecture's barrier rule, under which a repair or window move keeps the last public snapshot until it settles: a barrier law fails today on on-demand joined plans, where a visible-delete repair and a held `setWindow` publish before they settle (#2131; the law and traces are kept for that fix). The provider records a request as served only once it answers it, and an abort (or an adapter unload) of a held request follows `LoadSubsetOptions.signal`: a cooperative provider installs none of its rows and its load fulfills at once, while a provider that cannot cancel in-flight work (`late` cancellation, generated) answers it anyway and its load fulfills after those rows become visible. A `release-peer` step cleans up the peer while its loads are held, which aborts them; each request counts once although its acquisition release both aborts and unloads it. The fixed held campaign reaches 5 aborted held requests and 1 late answer, and its reconstruction check requires at least 3 cooperative cancellations and 1 late answer. A held load rejects when its applied receipt rejects, and the step then fails, because no signal or cleanup rejects a receipt in these histories; that branch is unwitnessed. The held campaign fixes a forwarding provider, so `release-peer` never reaches a refetching provider; that branch is unwitnessed. A request whose signal is already aborted when the provider receives it is never reached; that branch is unwitnessed. The held campaign uses a forwarding provider and an indexed source filter or a joined filter, deletes three visible rows and then upserts an eligible row with an id no other command uses; its reconstruction check runs in the same test as the fixed-seed campaign and reads its per-case counts: 35 of 40 cases issue a held request, and every one of them changes rows while it is in flight. Loading must reach a fixed point: past a request budget the provider rejects and records it, and the initial load, every step and the end of each history assert the record is clear (a loader that re-requests a prefix it already holds can loop without reaching the provider, which this cannot see). A bounded step makes at most four provider requests, and each consumer's initial proven-prefix load is bounded separately, in total transfers (a repeated request for the same prefix counts) and in distinct rows (at most the source's size); a loader that asks for 100 extra rows on every page fails that bound and the initial-load work law. Loader mutants that never resume after a boundary request, or that ignore a load request while another load is pending, fail it; a mutant that drops a window move blocked behind a pending load still survives, even with the settlement check on held moves; whether it is equivalent is open. Open (#2148): on an on-demand source with a joined or anti-joined filter, an optimistic delete of the visible row leaves the window short until the delete persists, because the next row is never loaded. On an on-demand source with a source filter, and on an eager source with any filter, the same delete refills the window correctly; these are the contrast cases. Owner: this oracle. Needed witness: the optimistic-delete dimension (kept as a patch for #2148) in the grammar, plus a fixed histories of the anti-join trace. A refetch that re-runs each page alone, as query-db-collection does today, leaves a row that moved between two pages unloaded; random seeds found that trace, a named fixed history fails on it, and a query-db receiving witness reproduces it (#2136 tracks the provider fix). An Electric-style provider, which skips updates to rows it never sent, is not modeled; a mocked-stream witness shows a row moving into a served ordered range never arriving under the default replica mode (#2138). At one times the run count, the campaigns reach 87 refetch steps: 39 change the window, 6 remove a visible row, and 13 lead to a further provider request. A random seed found a refetch that empties a joined window, after which the loader refills it over several page requests and publishes `[]`, one row, then the final rows; a named fixed history keeps that trace for #2131. No incorrect final window has appeared on that path, and no loader mutant tried so far is caught only by it; the work law does not cover it. For an on-demand joined or anti-joined filter, the initial load must not request the whole source when the first window's worth of source rows in query order all pass the join and no boundary value is null or NaN; 35 executed cases meet that premise, and a compiler that reads the whole source for every joined filter fails eight tests on this law alone. The rows served by that initial load stay within the prefix, its boundary ties and a refill, for each consumer. Later steps of those plans have no work bound. An unindexed join filter over it left the window empty on main, until the plan counted the rows it still needs; restoring the old guard fails the campaigns and a fixed histories. A step that changes the visible rows must publish, and its subscriber starts with every visible key, so a removal-only change reaches it; mutants that drop update-only or removal-only publications fail. A step may publish one intermediate window per provider request it made, and only its last publication must show the final window, because a repair or refill can publish after each round trip (#2131); the content of those intermediate windows is unchecked. The model keeps its own copy of the markers, and full-join histories can insert the root rows of orphan markers. A premise-directed campaign inserts a fresh row and then changes an initial row's eligibility alone, so the row leaves the window only through the join; it and a named fixed history fail when a window row past the loader's settled boundary counts toward a full window. One case in four adds a second live query over the same sources with a larger window, and its rows law holds at every checkpoint; the work laws do not apply to those cases. #2155: a second live-query Collection over a refetching on-demand provider kept a short joined window after a refetch removed a tied row (the peer showed ranks `[0, 0]` where the model shows `[0, 0, 100]`). The loader fix that resumes a staged continuation after a synchronous answer the graph already processed closed that trace. The fixed histories of that indexed trace now expects the right window. Still open: an unindexed peer over a refetching provider keeps a short joined window (the peer shows `[100]` where the model shows `[100, 101]`), which also fails without that fix; each campaign therefore still excludes the peer and refetching pairing, as its last map. Owner: this oracle. Needed witness: a fixed histories of the unindexed trace flipped to pass, and the pairing restored to the grammar. Limits: Effects are checked for rows after each step, not for publication counts or positions. Histories move the first query's window with `setWindow` (150 moves at one times the run count, 56 on demand), and its rows law and ledger law then follow the moved window; the Effect and the peer keep theirs. On a holding provider every window-move promise must have fulfilled after the final drain. A refetching provider models query-db's unload: it drops the unloaded request and removes rows that no remaining request's last answer holds. The per-step work law applies only to forwarding, immediate, single-consumer cases: it skips refetching, holding and peer cases and move steps; a two-term indexed move scanned the source four times, which no law here bounds. Multi-term joined plans stay outside this harness, on both paths; on the on-demand path, the provider's transfer is bounded only for indexed single-term source-predicate plans. Joined windows compare order values, so a wrong row tied with the expected one passes; a window row tied with the boundary counts as proven before its tie request settles, which no witness exercises. A boundary that cannot advance past a window row would repeat one page per new key; reading the boundary's range locally should always reach that row, but no witness shows it. Joined ties are compared as any valid first rows; whether source-filter ties may be fixed to the source key is open. The same grammar includes NaN and null ranks and a second-order term, and bounds both rows read and rows delivered, including the initial load and index refills; a scan law limits each change to one pass over the source. A fixed case checks that an indexed descending window places nulls first, and a lifecycle witness checks that an eager full-source window is ready with its rows at creation when its source answers synchronously. The initial fallback cells check indexed and unindexed locale strings with distinct and tied values, plus a null boundary after a warm ordered request. The warm null-boundary case crosses preload-started and constructor-started live-query sync runs: the former completes at the synchronous cut, while the latter resumes deferred on-demand acquisition asynchronously and publishes after settlement. They require an unfiltered full-source request, no public row at fallback entry, and the expected row and status at the immediate readiness check. A Promise fallback stays loading at that checkpoint. A React receiving witness checks an eager locale-string `findOne()` at the first layout commit. A Query DB receiving witness checks warm cached subsets: both raw adapter calls return literal `true` with the source rows visible at each return, and the live query is immediately ready without a fetch. The same fallback cells observe subscriber changes from before the first source write: literal-true and Promise fallbacks each publish one nonempty final-window insert after the fallback settles, and the callback sees the complete row. Promise cells show no nonempty event at the immediate cut; a duplicate-event fault fails all eight cells. Other adapters and a real on-demand React first commit remain unwitnessed. Full-source work after initial publication and repair remain outside this synchronous guarantee. A separate post-publication oracle crosses marker deletion and retargeting with literal, Promise, and held-applied-receipt fallback responses. It also settles the child demand before holding the receipt, so the public live-query loading flag isolates the root fallback. The model checks final rows and net events, while event replay checks every callback snapshot; a retained or empty intermediate window is allowed. A loader fault that ignores the pending receipt fails at the held loading-flag check. Two literal-true cleanup-failure histories require that the replacement still publishes and the adapter error reaches the public query status without making the unrelated marker commit throw; the provider's applied receipts are recorded and compared outside provider callbacks. The Promise case checks eventual coherence on an asynchronous callback path, without claiming a separate timing cut. Four held-Promise histories apply fallback writes, change another join marker before provider settlement, and compare each callback and the final winner; an injected stale extra publication fails the event comparison. Other marker actions and overlapping root-source changes remain open; the provider has no later write or rejection, so this schedule does not establish when the loader claims full-source authority. The reversed-index oracle checks every reversed read's order and filter work for both null placements. The retirement-invalidation grammar (`ordered-loader.retirement-invalidation`) composes a constructive invalidation prefix, up to four settled answers that let the repair chain complete, and generated command suffixes with index availability, limit 1/2, joined deferral, needed rows, release errors, reentrant truncate and disposal, reentrant ordinary passes, retry timers, offset 0/1, and full-source plans. Its declared `completed-repair-release` reach counts only an invalidating release observed while a successful repair-chain answer settles and issues no further chain step; an instrumented run matched the production retirement path in 66 of 72 such histories, and the rest owed nothing more after a late disposal or full coverage; an ablation that invalidates only on other releases passes the older `release-invalidation` reach and fails this one. Only an on-demand source answers a release with an adapter unload, so neither this grammar nor the broad `ordered-loader.request-authority` arbitrary pairs `releaseInvalidates` with a synchronous repair answer, which reads an eager source whose releases bypass the adapter (`CollectionSyncManager.unloadSubset`). The broad arbitrary weights `releaseInvalidates` 5:1 like the other release reactions, because an invalidating release leaves the quiet report laws. The directed grammar also places the invalidation after the callback's disposal, reentrant pass, and truncate (`late-release-invalidation`). Broad overlap states require 20 histories each, and the rarer full-source-plan overlap requires 5. An invalidating unload also writes graph input, so the driver's drain runs one more loader pass before it judges progress, as the live-query Collection and Effect graph loops do; without that pass, a repair that finishes inside a loader pass after joined work clears was misjudged as owing no further read. Its independent request ledger records issue and invalidation events on one clock. It requires an authoritative start after the callback's invalidation for a live finite repair after retry/drain; complete full-source coverage absorbs invalidation, cursor reset transfers pre-reset work to replay, disposal ends ownership, and unanswered authoritative work retains its existing no-timeout limit. Each normal broad campaign measures callback overlap with release error, truncate, disposal, pass reentry, offset one, timer-enabled scenarios, full-source plans, and multiple successor chains. A separate `ordered-loader.retirement-receiver` property runs real live-query Collection and Effect consumers, and a live-query Collection whose `setWindow` to the other offset starts in the same turn as the ordering write. It varies indexed/unindexed sources, limit 1/2, offset 0/1, literal/Promise adapter answers, a held newer replacement answer, and the unload's change: moving the repaired prefix, deleting its rows before the boundary, deleting the boundary row, or deleting a prefix row that ties with it, each of which owes a replacement, or evicting a sent row past the repair's boundary, including a row that ties with the boundary but lies outside the prefix, which keeps that boundary and starts no read. A tie row sorts before or after the boundary row by key, as live-query Collections order ties. A same-turn window move may repair either window's prefix, so that kind keeps only the move and row 1's eviction; prefix deletes and ties run on the other kinds. Each consumer has separate declared adapter-unload and held-answer reach, and delete, boundary-row delete, tie delete, tie eviction, and eviction unloads have their own reach; each grammar runs 600 histories. A loader that skipped every retirement-time delete fails the delete histories at the progress relation, one that treated every sent-row delete as new information fails the eviction histories, one that treated every boundary tie as eviction fails the boundary-row and tie deletes, one that treated every boundary tie as new information fails the tie evictions, and one that kept only the boundary row's key fails the tie deletes. The loader keeps the keys of the settled prefix rows that tie with its boundary row. The focused `keeps a repair's boundary when its retirement removes a row the loader sent` test remains as a supplemental example. An instrumented run placed every invalidating receiver unload inside retirement (200 of 200 per campaign). Following Atomic window publication, the window move stays pending while the replacement is held and settles with its chosen window visible; a mutant whose subset loads stop joining the active window operation fails only those move comparisons. Held-answer reach is recorded only after the provider answer is blocked and the prior-window and no-publication comparisons pass. The provider moves its repaired prefix behind a still-uninstalled row during unload and supplies no later external graph input. The shared progress relation checks that an authoritative start follows the invalidation and precedes the first replacement publication. Independent full-provider sorting checks final rows; held answers retain the previous complete public window and publish no callbacks, and completion publishes one complete replacement. Fixed replay witnesses force the held-answer premise for each consumer. An executed public-method call probe observed reentrant `loadMore` inside every sampled unload for both consumers, with no production-state reads. Original debt-clearing and deliberately delayed-one-pass mutants fail the progress/publication judgments; start observations before the invalidation or at publication fail calibration. This evidence does not require recursive synchronous dispatch: deferred replacement remains legal while the public gate holds. The replay-continuation grammar (`ordered-loader.replay-continuation`) follows ARCHITECTURE.md, Ordered requests/continuation/recovery: a pre-reset request belongs to truncate replay and cannot revive its discarded cursor; a graph continuation survives bookkeeping until a graph pass consumes it or cursor reset discards it. `continuationOwed` is model-only classification provenance, not product completion state. The unchanged handoff/reset grammar and exact shared-model replay reject consuming that provenance during replay bookkeeping. This is a harness correction; it supplies no production resource-leak claim. Its `deferred-refill` state is reached only when a synchronous repair answer meets a release-triggered truncate (6 of 96 inputs). An eager source's release never reaches the adapter (`CollectionSubscription.createSubsetAcquisitionRecord`), so production cannot produce that pairing, and the grammar calibrates continuation provenance without a production-reach claim. The broad request-authority arbitrary pairs synchronous repair answers with release faults, truncates, disposals, and reentrant passes in the same over-approximating way; those histories pass and support no production-reach claim either. The random handoff campaign runs 400 histories so that state stays above its minimum. Limits: the real receiver uses distinct finite numeric ranks, ascending root order, one source and consumer, one qualifying callback invalidation, no failure/truncate/disposal inside the real unload callback, one window move per history and no failed replacement under a move, and no provider-specific network. Its on-demand source settles every repair answer asynchronously in the loader, even a literal adapter answer; the loader's synchronous repair settlement needs an eager source, which has no adapter unload to invalidate. The controlled grammar measures the combined lifecycle cases; it does not transfer all such combinations across the public receiver boundary. No claim about obsolete later cursor-demand retirement (#2223) or arbitrary callback composition follows. Existing run budgets remain unchanged. Random samples do not establish bounded exhaustiveness. The controlled and receiving grammars have distinct registered replay properties, so each seed and shrink path selects only its own driver. The no-leak calibration supplies a retained-lease observation to the same request-ledger comparison after an unmodified run of the same covering-repair history; it reads no private member names. | -| Indexed predicate filtering | `packages/db/tests/query/index-path-collision-oracle.test.ts` | A bounded cross product of numeric values checks exact public keys before and after adding a nested-field BTree index. Direct predicates vary argument order, bound inclusivity, reversed operands, and bound field. Public Collection subscription and live-query callbacks run with and without the index. Selected-field ordering compares a direct JavaScript sort with a public `$selected` callback; restoring the dotted-key proxy cache made that comparison fail. The original compound grouping failed four indexed cases while scan controls passed. The original callback proxy caches also failed both unindexed public routes. A hostile cleanup control preserves the primary mismatch and secondary release error. Arbitrary path segments, nullish values, custom collation, and incremental publication need separate witnesses if claimed. | +| Indexed predicate filtering | `packages/db/tests/query/index-path-collision-oracle.test.ts` | A bounded cross product of numeric values checks exact public keys before and after adding a nested-field BTree index. Direct predicates vary argument order, bound inclusivity, reversed operands, and bound field. Public Collection subscription and live-query callbacks run with and without the index. Selected-field ordering compares a direct JavaScript sort with a public `$selected` callback; restoring the dotted-key proxy cache made that comparison fail. The original compound grouping failed four indexed cases while scan controls passed. The original callback proxy caches also failed both unindexed public routes. A mutant cleanup control preserves the primary mismatch and secondary release error. Arbitrary path segments, nullish values, custom collation, and incremental publication need separate witnesses if claimed. | | Lazy target path identity | `packages/db/tests/query/compiler/lazy-targets.test.ts` | A focused same-source `UnionFrom`/`coalesce` witness requires both [`a.b`] and [`a`, `b`] demand targets. Restoring dotted-string deduplication drops the second target at the compiler boundary. A public on-demand adapter and row-publication history still need a separate witness. | | Correlated include path identity | `packages/db/tests/query/includes-context-transport-oracle.test.ts` | The fixed path witnesses live in `packages/db/tests/query/includes-context-transport.test.ts`. A flat parent field and nested parent field reach one-level and nested `toArray` results independently; dotted ancestor aliases remain distinct through a grandchild route. Initial results and parent updates have exact public-value checks. A conditional projection gives [`a.b`] and [`a`, `b`] different include results and checks parent-key changes plus later child inserts. Removing the compiler's unique route-key allocator loses a public child result; restoring dotted-string deduplication at either builder site loses a distinct parent value. These fixed witnesses do not establish arbitrary path segments, every recursive source form, or every materialization form. | -| Alias scope identity | `packages/db/tests/query/includes-oracle.property.test.ts` (`includes alpha-renaming across lexical scopes`), `packages/db/tests/query/includes-scope-identity-oracle.ts`, `packages/db/tests/query/includes-alias-shadowing-oracle.test.ts`, `packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts`, `packages/db/tests/query/validate-aliases.test.ts`, [issue #1975 review](oracle-reviews/issue-1975-scope-identity.md) | Four topologies place a source beside a same-named source: a `from()` subquery with an include, a joined `unionAll()` branch with an include, a collapsed top-level pure wrapper with a join subquery, and an include directly on a joined `unionAll()`, which may reuse a branch alias. The grammar crosses zero to two joins, LEFT or INNER, zero to two predicates, plain, ordered-and-limited, or DISTINCT bodies, include forms, and eager or on-demand finite providers, after preload and after source writes. Includes read their source directly, through a join, through a `unionAll()` with an anchor join, or through a `from()` subquery that reads the parent row. One in four scenarios draws any naming; same-scope and union-branch repeats are rejected while ancestor shadowing is admitted. Each checkpoint compares the canonical naming with plain recomputation, the generated naming with the canonical naming, and per-Collection `loadSubset` WHERE clauses. The exact-output companion independently recomputes parent-child rows for direct, QueryRef, union, and implicit-join forms across two equality operand orders, shadowed and renamed aliases, eager and on-demand children, initial publication and parent/child writes. The recursive forms keep a required outer lockId correlation and an independent captured lockName predicate; the union has a nonempty right branch and a later two-branch overlap. It checks enumerable user and virtual key names, user values, multiplicity, sibling helper reuse, a fresh parent/inner QueryRef declaration across a source update, direct, child union-branch, parent union-branch, parent QueryRef, and nested-include rejection of ancestor/descendant builder reuse, one nested grandparent capture, functional callback child input, grouping, captured whole-row and nested-path spreads across shadowed and renamed aliases, eager and on-demand children, and parent/child writes, and query identity. Focused follow-up cells check same-path group keys under shadowing and renaming through parent and child writes, alpha-renamed pure wrappers at public-row and optimized-identity cuts, and indexed lazy demand for direct or wrapped nested includes through anchor and user writes. Wrong-query controls that drop either inner predicate or substitute a parent-shaped callback value fail at the initial public-row comparison. A finite follow-up crosses direct includes and joined QueryRefs with shadowed or renamed aliases, eager or on-demand sources, and child IDs equal to or distinct from an outer filter literal; it checks public rows and on-demand child admission after preload. Another follow-up projects a captured parent rank through a child QueryRef and checks public rows after preload, a child-rank change, and a parent-rank change. The original compiler misrouted the outer filter in four cases, and the original optimizer lost the captured binding in two cases at their first public-row comparison. Every pinned witness and both campaigns fail on `18abceee4`; each of the four optimizer re-mint sites fails a witness when reverted. Arbitrary nested includes, include-inside-subquery forms beyond the checked LEFT joined QueryRef, other join-subquery topologies, `having`, outer joins beyond the bounded RIGHT/FULL matrix, publication events, unload, ordered windows, other lazy join targets, and temporal demand remain outside these owners; they need witnesses before an alias-scope closure claim covers them. Ancestor/descendant builder reuse is rejected before correlation extraction; one helper builder remains legal across a parent QueryRef, union branch, or joined QueryRef and a sibling include child. Six placement/source-mode cells check construction, exact rows after preload, and a source update; the joined placement includes an unmatched manager that would expose a missing helper join. The original source-tree guard rejects all six. A discarded enumeration of a captured proxy cannot change the selected query identity or rows before or after a child update; the retained-marker implementation fails that identity check. The [PR #2079 follow-up](oracle-reviews/issue-2072-alias-shadowing-followup.md) records its RED trace, the repaired spread and nested-QueryRef cells, and the remaining compiler and optimizer evidence gaps. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds a self-join with an inner alias matching the outer joined source, a joined child whose local source competes with inherited parent context in matched and unmatched LEFT joins, captured/local spreads, grandparent whole-row capture, and direct captured-alias selection. These finite cells compare public rows after preload and selected writes; an alias-only lazy-target mutant fails the eager self-join comparison. The reported on-demand failure did not reproduce with the controlled source. A further finite matrix crosses nested FROM or JOIN placement, field or whole-row capture of a shadowed outer joined alias, shadowed or renamed spelling, and eager or on-demand acquisition. It compares source-role rows after preload, child deletion and movement, joined-row update and movement, and parent update; on-demand cases compare nonempty request sets. Disabling projected binding lookup fails all eight cells at the initial public-row assertion; an alias-only lazy-target mutant survives this matrix, so it adds no broader lazy-target claim. A bounded outer-join extension crosses RIGHT and FULL joins, main- or joined-side correlation, direct and QueryRef joined sources, shadowed and renamed aliases, eager and on-demand sources, two parent routes, and seven initial or post-write cuts. Its independent source-role model checks exact public rows, including both absent sides; on-demand cells also require source acquisition. A join-operand extension crosses an ancestor term combined with a local main or joined term, or serving as the whole main key, with equality reversal, alias renaming, source mode, and five public-row cuts. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) records RED and GREEN evidence. A foreign captured join operand must reject: a finite provenance matrix checks root and include placements, matching or different alias spelling, and eager or on-demand sources at construction or preload settlement; valid local and captured-ancestor operands remain controls. A two-LEFT-join history now checks that a later routed QueryRef or captured operand cannot restore an absent shadowed local source, across alias renaming, eager/on-demand mode, two parents, and local and second-joined arrival/removal. It also selects and updates a parent label while joins are matched, checking that the main route retains that ancestor value. A nested joined QueryRef control projects its join key from an inner tag source while the post alias shadows or renames an outer user alias; an independent plain-row model and installed on-demand post/tag rows are compared at preload, a user-key move, and a tag update. A wrong Collection/SourceId pair loads an irrelevant post at preload and fails the installed-row check. Other nested source shapes and real-provider request schedules remain outside this control. The route-work owner compares public rows and assembled parent-route work as irrelevant joined rows grow for direct main-correlated RIGHT/FULL joins in both source modes; an unconditional-route mutant grows from two to six pairs at preload. RIGHT joins correlated on main now join the outer-join row matrix, beside joined-side correlation and FULL controls. An include child rejects a bound join operand declared inside its parent QueryRef or union branch, across matching/renamed aliases and eager/on-demand sources; the original source-tree collector resolves preload instead of rejecting. A joined QueryRef rejects a captured source declared beside it in the enclosing query: the matrix crosses root/include placement, matching/renamed inner alias, and eager/on-demand sources; four true-ancestor controls assert rows, and the permissive sibling-scope mutant fails all eight invalid cells while those controls pass. The primary oracle now extends the hidden-source rejection through two QueryRef or union levels, with matching/renamed aliases and both source modes; a source-tree-binding mutant fails all eight added cells at the rejection cut. An unrelated bound reference is rejected in WHERE, GROUP BY, HAVING, ORDER BY, direct SELECT, and conditional SELECT across matching/renamed aliases; legal local controls and a grouped include with captured ancestor predicates, HAVING, ordering, shadowing, and parent/child writes prevent a blanket rejection. It also rejects foreign references moved into extracted include parent filters or into the removed correlation equality, each across alias spelling and source mode with valid parent controls. Disabling expression validation fails twelve non-join cells; omitting parent-filter or extracted-correlation validation fails four cells each. A separate plain relational model checks RIGHT-to-LEFT, RIGHT-to-RIGHT, FULL-to-FULL, and FULL-to-LEFT two-join chains under shadowing/renaming, both source modes, main/joined/tag correlation, and six source or ancestor writes; a later-FULL-as-LEFT mutant fails two initial public-row comparisons. The route-work owner now compares public rows and parent-route assembly counts at preload and four main-source writes while irrelevant joined rows grow. Other expression forms, arbitrary join-subquery and lazy-target shapes, deeper captures, other chained outer joins, real-provider schedules, and broader source-work bounds remain open under this owner. The alias-scope owner also checks outer grouping through a nested QueryRef and joined source with shadowed or renamed inner alias, eager or on-demand acquisition, and four source/tag writes at five public-row cuts. The tag insertion duplicates one joined source and the plain model counts both copies. A controlled rewrite probe reached an unbound pushed WHERE inside the QueryRef while the bound GROUP BY remained outside; an inner GROUP BY blocked that pushdown. A matcher mutant that ignored binding IDs failed two same-path group cells at the first public-row cut; a dropped-pushed-predicate mutant failed all four new cells by publishing an extra group. This refutes the reported WHERE-rewrite-to-GROUP-BY-matcher route in those shapes; another lost-provenance route needs a concrete fixture. Base/head work probes confirmed an extra pure-wrapper stage after #2079, but the same full-source outer-WHERE request and keyed lazy-join request on both revisions. No wrapper-stage work budget or safe binding remap is established. Bound root refs caused two route-metadata symbol checks per evaluation on the current head versus zero on base, while captured-ancestor rows remain protected by the primary oracle; a compile-time fast path needs a justified work budget and a preservation witness. The same function-form subquery placed twice compiled two results and invoked its callback four times for two source rows on both revisions. Cache comparison read more query properties on the current head, and a stateful callback produced placement-specific values. A callback identity or purity rule and work budget are needed before cache-equivalence changes. | -| Join equality and cold acquisition | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` | Independent recomputation for cold acquisition plus direct join/predicate equivalence across established equality domains. Binary/string and nullish classes, replacement histories, raw on-demand values, and both scan/auto-index paths are explicit; the compound lane also covers tuple equality, term order, all four join types, controlled cold sources, and keyed histories. Positive acquisition witnesses require a satisfiable tuple. | -| Aggregate selects | [aggregate select oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/aggregate-select-oracle.property.test.ts) | Generated select shapes of aggregate queries with and without `groupBy` by `c.k`, the pair `c.k`, `gt(c.v, 1)`, the triple that adds `gt(c.v, 2)`, `upper(c.k)`, `inArray(c.k, ['a', 'b'])`, a Date-valued `coalesce` or a `NaN`-valued `add`, rebuilt per use (literals, parent fields, the group key, the raw field `c.k`, source fields, `count`/`sum`/`max`, `add`, `caseWhen` with expression or object branches, nested objects, spreads, a nested include) and a HAVING condition that may compare an aggregate, selected as a field or not, at the top level, directly or through a `unionAll`, and in an include whose source may shadow the parent alias, through parent updates and source inserts, updates (including key changes that move a row to another group and route) and deletes. A shape throws `NonAggregateExpressionNotInGroupByError` exactly when a non-key source field appears outside an aggregate in its select or HAVING, or the shape spreads a row or holds a nested include; otherwise every published group equals the model and keeps the select's field order. The same grammar checks the opt-in operator laws: a grouped query whose select and HAVING hold no aggregate throws `GroupingOperatorRequiredError` when its keys are passed bare, and works through `groupKeys()`; a bare query builder throws `IncludeOperatorRequiredError` as a field, inside a nested object, or in a conditional branch; a wrapped include inside a nested object throws (a conditional branch may be an include; the includes-collection oracle publishes one, and a compiler that rejects one fails both that oracle and this one). When several errors apply, any one of them passes. Includes are wrapped in `toArray()`, `include()`, or `materialize()`. In three generated histories of six, the driver strips one kind of operator code, the grouping compiler, the include runtime, or the grouping compiler of the `orderBy` aggregate only, from the IR it built. One history in four is a forced history that strips the grouping compiler everywhere or from `orderBy` only, with no, one, or two group keys in turn, to sample each path through `new BaseQueryBuilder(ir)`; IR without its operator code must throw the coded error for the missing operator. A runtime-stripped history gives its include a child query that is valid on its own, so other errors cannot hide the missing runtime. Half the grouping-stripped histories give the top-level query a select of `count()` alone, with an aggregate HAVING when the generated shape has one, so a model error set of exactly `groupingRequired` witnesses the grouped, one-group, and HAVING paths. The top-level and include queries may order their groups with one or two `orderBy` calls, each on an aggregate or on a number or string select field through `$selected`, strings with the documented `en-US` collation: published rows and each parent's `kids` fall into blocks of equal values in that order. An `orderBy` aggregate that no top-level field selects throws `AggregateFunctionNotInSelectError` (now stated in the guide's "Ordering by SELECT Fields"), and a stripped `orderBy` aggregate compiles with the compiler that `groupKeys()` or a select or HAVING aggregate brings. One history in four takes ordered shapes from a grammar whose shapes compile, because generated ordered shapes compile in about one case in forty; the source starts with a key `A`, where locale and code-unit order differ. The property declares twenty-one operator states with `createOracleReach`. Each fixed and random campaign first executes twelve complete structural histories through the shared model, driver and comparison, then twelve seed-generated required histories and the unchanged 160 broad generated histories. One reach requires all twenty-one states in the structural set. This guarantee concerns initial publication or compilation only: these inputs have no mutation steps, shadowed alias, or union. A separate generated reach requires the generated histories themselves to enter all twenty-one states and seven additional checkpoints after parent updates, source inserts, updates, deletes, source route moves, a shadowed child alias comparison, and a union-source comparison. Those checkpoints count only after the public model comparison succeeds. The required grammar constructs one varied history per structural family, adding legal mutations and forcing shadowing and union on the string-ordered family. It guarantees the declared premises without relying on broad sampling; both reaches still fail on a miss and retain the selected seed and counts. The seven checkpoints do not require every operator state after every mutation or on every route; without the forced select, the one-group and HAVING states were never reached, and a mutant that throws the wrong error class only on the implicit path passed three campaigns. Pinned witnesses in `hand-built-ir-operators.test.ts` cover the old `IncludesSubquery` argument order, an aggregate only in `orderBy` without a select aggregate, and the mixed `orderBy` replay. The compiler before the order step took the query's grouping compiler fails the generated `orderBy` dimension (`top compiles`); an order step that ignores direction, keeps only the first `orderBy` call, or sorts strings by code unit, an include query that ignores direction, a group step that keeps only the first one or two keys, and a check that throws the wrong error class only beside HAVING each fail it (the last passed two of three campaigns before the alone states). A mutant without the include-runtime check, which publishes `kids: null`, and one without the grouping guard, which throws a `TypeError`, both fail it. A mutant that skips the grouping-operator check fails it with a `TypeError`, and one that treats a bare builder as an include fails it with the wrong error class. Open: an `orderBy` term on a raw expression other than an aggregate, and on boolean, Date, `NaN`, or object fields, whose order the docs do not state. The operator laws are generated only for aggregate and grouped selects; for plain selects, `includes.test.ts` pins a bare builder as a field, nested, and in a conditional, because this model publishes one row per group and needs a separate row-per-source model for them. The former seed-dependent string-order reach gap recurred in the main release run on 2026-11-02. The structural histories guarantee the initial premises. They cannot satisfy the separate generated reach. The model, comparisons, reach minimum, fixed seed 2094, and 160 generated runs per campaign remain unchanged. Generated history choices now use named fields rather than a positional tuple. Shared parameter helpers report each selected seed before execution, so a final reach failure after fast-check success has a seed-only campaign replay. Statistics use the exact value `TANSTACK_DB_ORACLE_STATISTICS=1` and survive comparison or reach failures. Removing the structural string history fails the fixed-reach check even when generated histories reach strings. Selecting code-unit order instead of the documented locale order fails the ordinary published-row comparison; seed 2094/path 11 replays that failure directly. The [original repair record](oracle-reviews/2026-11-02-aggregate-select-reach.md) preserves historical evidence. The [nine-item review record](oracle-reviews/2026-11-02-aggregate-select-review.md) records historical counts, generator ablations, replay diagnostics, reproducible commands, and remaining limits. The [release CI repair](oracle-reviews/2026-11-05-release-ci.md) records the recurrent sampled-reach failure, constructive generated-prefix repair, measured reach, and unchanged ordering mutant. | +| Alias scope identity | `packages/db/tests/query/includes-oracle.property.test.ts` (`includes alpha-renaming across lexical scopes`), `packages/db/tests/query/includes-scope-identity-oracle.ts`, `packages/db/tests/query/includes-alias-shadowing-oracle.test.ts`, `packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts`, `packages/db/tests/query/validate-aliases.test.ts`, [issue #1975 review](oracle-reviews/issue-1975-scope-identity.md) | Four topologies place a source beside a same-named source: a `from()` subquery with an include, a joined `unionAll()` branch with an include, a collapsed top-level pure wrapper with a join subquery, and an include directly on a joined `unionAll()`, which may reuse a branch alias. The grammar crosses zero to two joins, LEFT or INNER, zero to two predicates, plain, ordered-and-limited, or DISTINCT bodies, include forms, and eager or on-demand finite providers, after preload and after source writes. Includes read their source directly, through a join, through a `unionAll()` with an anchor join, or through a `from()` subquery that reads the parent row. One in four scenarios draws any naming; same-scope and union-branch repeats are rejected while ancestor shadowing is admitted. Each checkpoint compares the canonical naming with plain recomputation, the generated naming with the canonical naming, and per-Collection `loadSubset` WHERE clauses. The exact-output companion independently recomputes parent-child rows for direct, QueryRef, union, and implicit-join forms across two equality operand orders, shadowed and renamed aliases, eager and on-demand children, initial publication and parent/child writes. The recursive forms keep a required outer lockId correlation and an independent captured lockName predicate; the union has a nonempty right branch and a later two-branch overlap. It checks enumerable user and virtual key names, user values, multiplicity, sibling helper reuse, a fresh parent/inner QueryRef declaration across a source update, direct, child union-branch, parent union-branch, parent QueryRef, and nested-include rejection of ancestor/descendant builder reuse, one nested grandparent capture, functional callback child input, grouping, captured whole-row and nested-path spreads across shadowed and renamed aliases, eager and on-demand children, and parent/child writes, and query identity. Focused follow-up cells check same-path group keys under shadowing and renaming through parent and child writes, alpha-renamed pure wrappers at public-row and optimized-identity cuts, and indexed lazy demand for direct or wrapped nested includes through anchor and user writes. Wrong-query controls that drop either inner predicate or substitute a parent-shaped callback value fail at the initial public-row comparison. A finite follow-up crosses direct includes and joined QueryRefs with shadowed or renamed aliases, eager or on-demand sources, and child IDs equal to or distinct from an outer filter literal; it checks public rows and on-demand child admission after preload. Another follow-up projects a captured parent rank through a child QueryRef and checks public rows after preload, a child-rank change, and a parent-rank change. The original compiler misrouted the outer filter in four cases, and the original optimizer lost the captured binding in two cases at their first public-row comparison. Every fixed histories and both campaigns fail on `18abceee4`; each of the four optimizer re-mint sites fails a witness when reverted. Arbitrary nested includes, include-inside-subquery forms beyond the checked LEFT joined QueryRef, other join-subquery topologies, `having`, outer joins beyond the bounded RIGHT/FULL matrix, publication events, unload, ordered windows, other lazy join targets, and temporal demand remain outside these owners; they need witnesses before an alias-scope closure claim covers them. Ancestor/descendant builder reuse is rejected before correlation extraction; one helper builder remains legal across a parent QueryRef, union branch, or joined QueryRef and a sibling include child. Six placement/source-mode cells check construction, exact rows after preload, and a source update; the joined placement includes an unmatched manager that would expose a missing helper join. The original source-tree guard rejects all six. A discarded enumeration of a captured proxy cannot change the selected query identity or rows before or after a child update; the retained-marker implementation fails that identity check. The [PR #2079 follow-up](oracle-reviews/issue-2072-alias-shadowing-followup.md) records its RED trace, the repaired spread and nested-QueryRef cells, and the remaining compiler and optimizer evidence gaps. The [ad5e0108 review](oracle-reviews/issue-2072-alias-shadowing-ad5e0108.md) adds a self-join with an inner alias matching the outer joined source, a joined child whose local source competes with inherited parent context in matched and unmatched LEFT joins, captured/local spreads, grandparent whole-row capture, and direct captured-alias selection. These finite cells compare public rows after preload and selected writes; an alias-only lazy-target mutant fails the eager self-join comparison. The reported on-demand failure did not reproduce with the controlled source. A further finite matrix crosses nested FROM or JOIN placement, field or whole-row capture of a shadowed outer joined alias, shadowed or renamed spelling, and eager or on-demand acquisition. It compares source-role rows after preload, child deletion and movement, joined-row update and movement, and parent update; on-demand cases compare nonempty request sets. Disabling projected binding lookup fails all eight cells at the initial public-row assertion; an alias-only lazy-target mutant survives this matrix, so it adds no broader lazy-target claim. A bounded outer-join extension crosses RIGHT and FULL joins, main- or joined-side correlation, direct and QueryRef joined sources, shadowed and renamed aliases, eager and on-demand sources, two parent routes, and seven initial or post-write cuts. Its independent source-role model checks exact public rows, including both absent sides; on-demand cells also require source acquisition. A join-operand extension crosses an ancestor term combined with a local main or joined term, or serving as the whole main key, with equality reversal, alias renaming, source mode, and five public-row cuts. The [outer-join follow-up](oracle-reviews/issue-2072-alias-shadowing-outer-joins.md) records RED and GREEN evidence. A foreign captured join operand must reject: a finite provenance matrix checks root and include placements, matching or different alias spelling, and eager or on-demand sources at construction or preload settlement; valid local and captured-ancestor operands remain controls. A two-LEFT-join history now checks that a later routed QueryRef or captured operand cannot restore an absent shadowed local source, across alias renaming, eager/on-demand mode, two parents, and local and second-joined arrival/removal. It also selects and updates a parent label while joins are matched, checking that the main route retains that ancestor value. A nested joined QueryRef control projects its join key from an inner tag source while the post alias shadows or renames an outer user alias; an independent plain-row model and installed on-demand post/tag rows are compared at preload, a user-key move, and a tag update. A wrong Collection/SourceId pair loads an irrelevant post at preload and fails the installed-row check. Other nested source shapes and real-provider request schedules remain outside this control. The route-work owner compares public rows and assembled parent-route work as irrelevant joined rows grow for direct main-correlated RIGHT/FULL joins in both source modes; an unconditional-route mutant grows from two to six pairs at preload. RIGHT joins correlated on main now join the outer-join row matrix, beside joined-side correlation and FULL controls. An include child rejects a bound join operand declared inside its parent QueryRef or union branch, across matching/renamed aliases and eager/on-demand sources; the original source-tree collector resolves preload instead of rejecting. A joined QueryRef rejects a captured source declared beside it in the enclosing query: the matrix crosses root/include placement, matching/renamed inner alias, and eager/on-demand sources; four true-ancestor controls assert rows, and the permissive sibling-scope mutant fails all eight invalid cells while those controls pass. The primary oracle now extends the hidden-source rejection through two QueryRef or union levels, with matching/renamed aliases and both source modes; a source-tree-binding mutant fails all eight added cells at the rejection cut. An unrelated bound reference is rejected in WHERE, GROUP BY, HAVING, ORDER BY, direct SELECT, and conditional SELECT across matching/renamed aliases; legal local controls and a grouped include with captured ancestor predicates, HAVING, ordering, shadowing, and parent/child writes prevent a blanket rejection. It also rejects foreign references moved into extracted include parent filters or into the removed correlation equality, each across alias spelling and source mode with valid parent controls. Disabling expression validation fails twelve non-join cells; omitting parent-filter or extracted-correlation validation fails four cells each. A separate plain relational model checks RIGHT-to-LEFT, RIGHT-to-RIGHT, FULL-to-FULL, and FULL-to-LEFT two-join chains under shadowing/renaming, both source modes, main/joined/tag correlation, and six source or ancestor writes; a later-FULL-as-LEFT mutant fails two initial public-row comparisons. The route-work owner now compares public rows and parent-route assembly counts at preload and four main-source writes while irrelevant joined rows grow. Other expression forms, arbitrary join-subquery and lazy-target shapes, deeper captures, other chained outer joins, real-provider schedules, and broader source-work bounds remain open under this owner. The alias-scope owner also checks outer grouping through a nested QueryRef and joined source with shadowed or renamed inner alias, eager or on-demand acquisition, and four source/tag writes at five public-row cuts. The tag insertion duplicates one joined source and the plain model counts both copies. A controlled rewrite probe reached an unbound pushed WHERE inside the QueryRef while the bound GROUP BY remained outside; an inner GROUP BY blocked that pushdown. A matcher mutant that ignored binding IDs failed two same-path group cells at the first public-row cut; a dropped-pushed-predicate mutant failed all four new cells by publishing an extra group. This refutes the reported WHERE-rewrite-to-GROUP-BY-matcher route in those shapes; another lost-provenance route needs a concrete fixture. Base/head work probes confirmed an extra pure-wrapper stage after #2079, but the same full-source outer-WHERE request and keyed lazy-join request on both revisions. No wrapper-stage work budget or safe binding remap is established. Bound root refs caused two route-metadata symbol checks per evaluation on the current head versus zero on base, while captured-ancestor rows remain protected by the primary oracle; a compile-time fast path needs a justified work budget and a preservation witness. The same function-form subquery placed twice compiled two results and invoked its callback four times for two source rows on both revisions. Cache comparison read more query properties on the current head, and a stateful callback produced placement-specific values. A callback identity or purity rule and work budget are needed before cache-equivalence changes. | +| Join equality and cold acquisition | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` | Independent recomputation for cold acquisition plus direct join/predicate equivalence across established equality domains. Binary/string and nullish classes, replacement histories, raw on-demand values, and both scan/auto-index paths are explicit; the compound grammar also covers tuple equality, term order, all four join types, controlled cold sources, and keyed histories. Positive acquisition witnesses require a satisfiable tuple. | +| Aggregate selects | [aggregate select oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/aggregate-select-oracle.property.test.ts) | Generated select shapes of aggregate queries with and without `groupBy` by `c.k`, the pair `c.k`, `gt(c.v, 1)`, the triple that adds `gt(c.v, 2)`, `upper(c.k)`, `inArray(c.k, ['a', 'b'])`, a Date-valued `coalesce` or a `NaN`-valued `add`, rebuilt per use (literals, parent fields, the group key, the raw field `c.k`, source fields, `count`/`sum`/`max`, `add`, `caseWhen` with expression or object branches, nested objects, spreads, a nested include) and a HAVING condition that may compare an aggregate, selected as a field or not, at the top level, directly or through a `unionAll`, and in an include whose source may shadow the parent alias, through parent updates and source inserts, updates (including key changes that move a row to another group and route) and deletes. A shape throws `NonAggregateExpressionNotInGroupByError` exactly when a non-key source field appears outside an aggregate in its select or HAVING, or the shape spreads a row or holds a nested include; otherwise every published group equals the model and keeps the select's field order. The same grammar checks the opt-in operator laws: a grouped query whose select and HAVING hold no aggregate throws `GroupingOperatorRequiredError` when its keys are passed bare, and works through `groupKeys()`; a bare query builder throws `IncludeOperatorRequiredError` as a field, inside a nested object, or in a conditional branch; a wrapped include inside a nested object throws (a conditional branch may be an include; the includes-collection oracle publishes one, and a compiler that rejects one fails both that oracle and this one). When several errors apply, any one of them passes. Includes are wrapped in `toArray()`, `include()`, or `materialize()`. In three generated histories of six, the driver strips one kind of operator code, the grouping compiler, the include runtime, or the grouping compiler of the `orderBy` aggregate only, from the IR it built. One history in four is a forced history that strips the grouping compiler everywhere or from `orderBy` only, with no, one, or two group keys in turn, to sample each path through `new BaseQueryBuilder(ir)`; IR without its operator code must throw the coded error for the missing operator. A runtime-stripped history gives its include a child query that is valid on its own, so other errors cannot hide the missing runtime. Half the grouping-stripped histories give the top-level query a select of `count()` alone, with an aggregate HAVING when the generated shape has one, so a model error set of exactly `groupingRequired` witnesses the grouped, one-group, and HAVING paths. The top-level and include queries may order their groups with one or two `orderBy` calls, each on an aggregate or on a number or string select field through `$selected`, strings with the documented `en-US` collation: published rows and each parent's `kids` fall into blocks of equal values in that order. An `orderBy` aggregate that no top-level field selects throws `AggregateFunctionNotInSelectError` (now stated in the guide's "Ordering by SELECT Fields"), and a stripped `orderBy` aggregate compiles with the compiler that `groupKeys()` or a select or HAVING aggregate brings. One history in four takes ordered shapes from a grammar whose shapes compile, because generated ordered shapes compile in about one case in forty; the source starts with a key `A`, where locale and code-unit order differ. The property declares twenty-one operator states with `createOracleReach`. Each fixed and random campaign first executes twelve complete structural histories through the shared model, driver and comparison, then twelve seed-generated required histories and the unchanged 160 broad generated histories. One reach requires all twenty-one states in the structural set. This guarantee concerns initial publication or compilation only: these inputs have no mutation steps, shadowed alias, or union. A separate generated reach requires the generated histories themselves to enter all twenty-one states and seven additional checkpoints after parent updates, source inserts, updates, deletes, source route moves, a shadowed child alias comparison, and a union-source comparison. Those checkpoints count only after the public model comparison succeeds. The required grammar constructs one varied history per structural family, adding legal mutations and forcing shadowing and union on the string-ordered family. It guarantees the declared premises without relying on broad sampling; both reaches still fail on a miss and retain the selected seed and counts. The seven checkpoints do not require every operator state after every mutation or on every route; without the forced select, the one-group and HAVING states were never reached, and a mutant that throws the wrong error class only on the implicit path passed three campaigns. Fixed histories in `hand-built-ir-operators.test.ts` cover the old `IncludesSubquery` argument order, an aggregate only in `orderBy` without a select aggregate, and the mixed `orderBy` replay. The compiler before the order step took the query's grouping compiler fails the generated `orderBy` dimension (`top compiles`); an order step that ignores direction, keeps only the first `orderBy` call, or sorts strings by code unit, an include query that ignores direction, a group step that keeps only the first one or two keys, and a check that throws the wrong error class only beside HAVING each fail it (the last passed two of three campaigns before the alone states). A mutant without the include-runtime check, which publishes `kids: null`, and one without the grouping guard, which throws a `TypeError`, both fail it. A mutant that skips the grouping-operator check fails it with a `TypeError`, and one that treats a bare builder as an include fails it with the wrong error class. Open: an `orderBy` term on a raw expression other than an aggregate, and on boolean, Date, `NaN`, or object fields, whose order the docs do not state. The operator laws are generated only for aggregate and grouped selects; for plain selects, `includes.test.ts` checks a bare builder as a field, nested, and in a conditional, because this model publishes one row per group and needs a separate row-per-source model for them. The former seed-dependent string-order reach gap recurred in the main release run on 2026-11-02. The structural histories guarantee the initial premises. They cannot satisfy the separate generated reach. The model, comparisons, reach minimum, fixed seed 2094, and 160 generated runs per campaign remain unchanged. Generated history choices now use named fields rather than a positional tuple. Shared parameter helpers report each selected seed before execution, so a final reach failure after fast-check success has a reseeded campaign. Statistics use the exact value `TANSTACK_DB_ORACLE_STATISTICS=1` and survive comparison or reach failures. Removing the structural string history fails the fixed-reach check even when generated histories reach strings. Selecting code-unit order instead of the documented locale order fails the ordinary published-row comparison; seed 2094/path 11 replays that failure directly. The [original repair record](oracle-reviews/2026-11-02-aggregate-select-reach.md) preserves historical evidence. The [nine-item review record](oracle-reviews/2026-11-02-aggregate-select-review.md) records historical counts, generator ablations, replay diagnostics, reproducible commands, and remaining limits. The [release CI repair](oracle-reviews/2026-11-05-release-ci.md) records the recurrent sampled-reach failure, constructive generated-prefix repair, measured reach, and unchanged ordering mutant. | | Query optimizer semantics | `packages/db/tests/query/optimizer-semantics-oracle.test.ts`, [aggregate review](oracle-reviews/2026-09-28-optimizer-aggregate-pushdown.md), [QueryRef repair](oracle-reviews/2026-09-30-queryref-publication-repair.md), [residual review](oracle-reviews/2026-10-01-pr-1977-residual-convergence.md) | Independent sum recomputation and a materialized-Collection formulation check the first public snapshot of a nested aggregate under a left join. Direct, arithmetic-wrapped, and conditional aggregates distinguish safe from unsafe pushdown; a grouped-key control covers the grouped boundary. Matching and nonmatching global aggregates also cross a nested QueryRef inner join with absent, accepting, and rejecting outer predicates. The matching initial cases fail on the pre-repair revision and pass after the computed-projection lookup repair. A separate fixed residual-convergence grammar checks exact predicate and outer WHERE clause counts at `optimizeQuery` return and after re-optimization: LEFT, RIGHT, INNER, and FULL joins; equality, greater-than, nullable-field `isUndefined`, same-source OR, cross-source equality, clause order, separate/AND form, two active predicates, two residual sources, and an existing residual marker. Plain-array LEFT/RIGHT and three-source join models check first public rows, including unmatched rows. Arbitrary expressions, more complex join chains, and incremental optimizer/publication histories remain outside this owner. | -| QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, shared `subquery-literal-value-oracle.ts`, supporting `subquery-literal-value-driver.ts`, correlated `subquery-literal-correlated-oracle.test.ts` / `subquery-literal-correlated-oracle.ts` with concrete factory/driver support, supplemental `subquery-user-value.test.ts` and `transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md), [chunk 19](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md) | The independent relational models own joined DISTINCT with outer WHERE, aggregate-source updates and joined findOne histories. Other fixed container/no-select/functional/predicate-literal examples are supplemental ordinary tests, not independent oracle scope. Chunk 19 adds one ordinary-JavaScript literal model with 17 forms × two positions × root/QueryRef receivers: 68 histories and three cuts each. It checks complete ordinary rows, event bags/current/previous values, event-time reads and immutable QueryRef callback inputs, with all 204 receiving and 102 callback states reached once. Strict exact-driver admission passes; unchanged production has eight undefined-to-null REDs (P11), ten empty-container-loss REDs (P12), 50 passing new histories and six passing relational neighbors. Twenty-four hostile and four lawful controls pass; partial selections still fail mandatory reach. Chunk 20 receives the same literal law through toArray, array/singleton materialize and Collection facades: 272 exact histories, all 3,264 path/cut states once, 200 main passes and 72 intended P11/P12 REDs; 33 checker controls and six independent facet controls pass. Legal QueryRef children have their own correlation WHERE and preserve the authored routeGroup input until actual projection removes it. Facade layouts are ordered, startup callbacks permit canonical empty values but owe complete ready inputs, and all 272 titles remain distinct for precise selection. Chunk 21 adds a separate source-public type judgment (`subquery-literal-types-oracle.ts` / `.test.ts`, supporting `subquery-literal-types-support.ts`) for those exact 340 authored receivers: 1,870 actual QueryResult, Collection-row, contextual-input, inline and facade/API markers. All consumers compile without diagnostics under TypeScript 5.9.3, and all 2,550 syntax/admission/marker states execute once. Unchanged production has 280 intended RED histories: 40 raw-nullish precision failures and 272 contextual-input or propagated-inline metadata failures, with 32 overlapping; 60 native histories and 55 checker/source controls pass. Independent review exposed erased call/construct signatures on records; the recorder now retains those declared capabilities and eight existing empty/nonempty × root/nested record controls reject invented signatures, after all eight failed under the original checker. Only the separate four virtual fields at named query-output or published/source Collection row roots are excluded from this ordinary-value judgment; callbacks and inline children remain strict. The full static callback metadata grammar remains open under existing `virtual-row-fields-types-oracle.test.ts`, with the literal sources as receiving witnesses of its accepted P10 law. Source-public inference does not establish declaration portability; an exported inferred factory currently gives TS4058 and is preserved for O22, including exported Collection and built/installed controls. Broader mixed-expression admission, sparse authored arrays, symbols, ordinary-record readonly modifiers, descriptor flags, arbitrary recursion and provider/framework schedules remain open with their sequential receiving owners. Metadata attribution stays with its separate owner; the four reserved fields are excluded only at each observed root row. No production repair or closure is claimed. | +| QueryRef operators and user-value boundaries | `packages/db/tests/query/subquery-user-value-oracle.test.ts`, shared `subquery-literal-value-oracle.ts`, supporting `subquery-literal-value-driver.ts`, correlated `subquery-literal-correlated-oracle.test.ts` / `subquery-literal-correlated-oracle.ts` with concrete factory/driver support, supplemental `subquery-user-value.test.ts` and `transactions.test.ts`, [repair record](oracle-reviews/2026-09-30-queryref-publication-repair.md), [chunk 19](oracle-reviews/2026-10-28-pr-2171-oracle-chunks.md) | The independent relational models own joined DISTINCT with outer WHERE, aggregate-source updates and joined findOne histories. Other fixed container/no-select/functional/predicate-literal examples are supplemental ordinary tests, not independent oracle scope. Chunk 19 adds one ordinary-JavaScript literal model with 17 forms × two positions × root/QueryRef receivers: 68 histories and three cuts each. It checks complete ordinary rows, event bags/current/previous values, event-time reads and immutable QueryRef callback inputs, with all 204 receiving and 102 callback states reached once. Strict exact-driver admission passes; unchanged production has eight undefined-to-null REDs (P11), ten empty-container-loss REDs (P12), 50 passing new histories and six passing relational neighbors. Twenty-four mutant and four lawful controls pass; partial selections still fail mandatory reach. Chunk 20 receives the same literal law through toArray, array/singleton materialize and Collection facades: 272 exact histories, all 3,264 path/cut states once, 200 main passes and 72 intended P11/P12 REDs; 33 checker controls and six independent facet controls pass. Legal QueryRef children have their own correlation WHERE and preserve the authored routeGroup input until actual projection removes it. Facade layouts are ordered, startup callbacks permit canonical empty values but owe complete ready inputs, and all 272 titles remain distinct for precise selection. Chunk 21 adds a separate source-public type judgment (`subquery-literal-types-oracle.ts` / `.test.ts`, supporting `subquery-literal-types-support.ts`) for those exact 340 authored receivers: 1,870 actual QueryResult, Collection-row, contextual-input, inline and facade/API markers. All consumers compile without diagnostics under TypeScript 5.9.3, and all 2,550 syntax/admission/marker states execute once. Unchanged production has 280 intended RED histories: 40 raw-nullish precision failures and 272 contextual-input or propagated-inline metadata failures, with 32 overlapping; 60 native histories and 55 checker/source controls pass. Independent review exposed erased call/construct signatures on records; the recorder now retains those declared capabilities and eight existing empty/nonempty × root/nested record controls reject invented signatures, after all eight failed under the original checker. Only the separate four virtual fields at named query-output or published/source Collection row roots are excluded from this ordinary-value judgment; callbacks and inline children remain strict. The full static callback metadata grammar remains open under existing `virtual-row-fields-types-oracle.test.ts`, with the literal sources as receiving witnesses of its accepted P10 law. Source-public inference does not establish declaration portability; an exported inferred factory currently gives TS4058 and is preserved for O22, including exported Collection and built/installed controls. Broader mixed-expression admission, sparse authored arrays, symbols, ordinary-record readonly modifiers, descriptor flags, arbitrary recursion and provider/framework schedules remain open with their sequential receiving owners. Metadata attribution stays with its separate owner; the four reserved fields are excluded only at each observed root row. No production repair or closure is claimed. | | Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | -| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand lane checks that an on-demand load issued before the change listener runs does not fetch until the listener runs or cannot start, through a real live query and the subset boundary: 16 histories cross listen, subscribe rejection, reader failure, abort, and cleanup with a service change before the subscription; the adapter from before the wait fails all 16. Its page-event lane checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end lane pins that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page lane (`trailbase.page-event`) crosses two-page loads, cursor and offset paging, overlapping on-demand loads, and up to four changes among page releases. A held-commit lane checks that a page never fails a load for an older event that core holds while a user mutation persists; deciding insert or update from visible rows fails it. After a generated history drains, the Collection holds exactly the rows a page returned or an event touched. Both page lanes deliver events for changes made before the list call at once, during the page, or after it applies, and check that the listener survives a late insert for a row a page already holds. The retry-decision property (`electric.retry-decision`) generates histories over full-log and progressive waits, with and without `onError`, crossing synchronous and asynchronous retries and give-ups, a throwing `onError`, a later error after a retry, an Electric client give-up, an external abort before or during a decision, new demands, the first up-to-date, a persisting optimistic transaction that holds the retried snapshot and later settles, and cleanup. The retry-decision grammar also crosses eager and changes-only sources, an external signal already aborted at sync start, and a status listener that cleans up inside the failure; it checks that a readiness wait rejects with the same cause as every waiting demand, and that such a cleanup leaves a rejected demand rejected. It also holds the acceptance of the first up-to-date commit across later errors, retries, give-ups, and aborts, and checks that the Collection becomes ready with the snapshot's rows when the commit is accepted, and fails with the acceptance's cause when it is not. The descriptor-isolation oracle holds that acceptance across a managed-cache rotation and checks that the retired session's acceptance settles nothing, whether it resolves or rejects, and that the replacement session's snapshot decides readiness. A progressive snapshot's truncate applies at once; a demand whose snapshot arrived before an external abort settles when its rows apply, and cleanup after the abort rejects it. An independent model checks the status, every demand's settlement and cause, and row visibility after each step: a source that still tries stays `loading`, and only a give-up fails its waits. `main` before #2127 and mutants that fail before the decision, skip the failure on a throw or give-up, or skip `markError` on a client give-up or an external abort each fail it at an assertion. An installed-client lane in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/current-generation durable rows, late old-response rejection, and final cleanup comparisons remain. Each round also remembers its retired storage target and checks that the late response's absent key reaches neither that target after delivery nor after replacement settlement. The seventh declared state requires this retired-target observation. This constrains the expired session's row; unrelated retained rows remain allowed, and a separate run with a valid claim can still use a retired generation. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated lane. | +| 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 grammar 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 grammar checks that a page read before a concurrent change event neither reverts nor resurrects that row, and its stream-end grammar checks that the adapter does not resubscribe: 17 histories, 6 of which fail on the adapter before the page fence. A generated page grammar (`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 grammar 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 grammar 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 grammar in the SDK delivery oracle drives the same model (`electric-retry-decision-model.ts`) through the real Electric client and HTTP: a retried 503, a response without Electric headers, and an exhausted 50-retry budget, in eager, full-log, and progressive modes, with a synchronous or asynchronous retry (18 cells). Skipping `markError` on the client's give-up fails the 12 give-up cells; failing before the decision, or `main` before #2127, fails 15; the 3 survivors give up in the same continuation that decides, so an early failure is not observable there. A mutant that settles a held demand on acceptance instead of visibility fails the held replays. Excluded: stream steps while `onError` decides or after a final failure, which the client cannot produce. The source-release property (`electric.sdk-source-release`) checks that cleanup removes every `visibilitychange` listener that the installed client registered for a sync run's streams: one to three runs in eager, on-demand, and progressive modes, each answering up to three requests with row-only delivery, an up-to-date, an HTTP 409 reset, or a retried 503, then staying open, giving up, aborting the external signal, or holding an asynchronous retry/give-up decision through cleanup and later runs, with restarts through `preload()`. It declares separate cleanup reach for partial delivery, reset before a later up-to-date, and a pending error decision. The minimal partial/reset histories cross all five endings in every sync mode through the same driver. It compares the live listener count with the number of active runs after each step. The recorder tracks ordered callback/capture identities with duplicate-add semantics; all 8,421 add/remove histories of length zero to three over two callbacks and five capture spellings compare its count with native EventTarget dispatch before teardown. Recorder teardown independently removes retained registrations after the comparison; bounded callback-subset enumeration checks actual EventTarget dispatch and preservation of a primary failure. `main` before the fix, and mutants that release only while the stream is unaborted or only without a final initial failure, fail it at cleanup; the generated managed-rotation property (`electric.sdk-rotation-release`) checks the approved Electric deadline before replacement `ShapeStream` construction. It rotates one to three times in one on-demand sync run, so a replacement session is itself retired. Each old HTTP snapshot arrives during held rotation, before replacement snapshots, or after their settlement. It declares reach for all three delivery cuts, initial/replacement session retirement, and demands awaiting replacement source responses. A transparent constructor observer records zero retired listeners immediately before the real SDK constructor; both retained demands stay pending while rotation is held and before any replacement response, then fulfill after their source snapshots apply. Fresh public/current-generation durable rows, late old-response rejection, and final cleanup comparisons remain. Each round also remembers its retired storage target and checks that the late response's absent key reaches neither that target after delivery nor after replacement settlement. The seventh declared state requires this retired-target observation. This constrains the expired session's row; unrelated retained rows remain allowed, and a separate run with a valid claim can still use a retired generation. Assertion inputs copy mutable observations so cleanup cannot change their failure payload. The browser listener is observed in jsdom; React Native's AppState adapter and overlapping rotations are outside this generated grammar. | | PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | 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) (fixed histories and the source-commit scheduler-order test live in `packages/browser-db-sqlite-persistence/tests/browser-coordinator.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), [persisted sync problems](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts), [persisted reload replacement](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.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 persisted sync-problem oracle (`packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts`) owns how the wrapper reports failures of its own remote work and to which sync run: a later successful remote request ends the wrapper's remote failures (maintainer decision: a coarse clear, by any remote request that succeeds, the same one repeated or another; 8 histories failed before it, and a wrapper without the clear fails 7); 35 histories over eight sites (coordinator release, the adapter's own `unloadSubset` throw or reject, scoped refresh ensure and release, rotated reacquisition through a routed or single-process coordinator, and a reload's cleanup release), each crossed with ready or loading and with the current run or a run that `reload()` retired before or after the new run's `markReady()`. A remote failure is a ready Collection's failure reason and never a crash; before the first ready it is only logged; an `unloadSubset` breach surfaces uncaught; a retired run's failures change nothing in the new run and never reject its cleanup. HEAD failed 29 histories; mutants that crash, remove the generation fence, or let a cleanup release reject each fail. Limits: controlled adapter and coordinator doubles; no Browser or Electron follower routing; no coordinator retry queue. The persisted reload-replacement oracle (`packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts`) checks over 40 histories that the wrapper forwards `begin({ snapshot })` and `truncate({ markReady })`, that its restore and metadata commits replace nothing and do not complete a reload, and that durable rows equal visible rows after a replacement. HEAD's wrapper failed 15; a dropped snapshot, a non-durable snapshot, dropped truncate options, and a restore marked as a snapshot each fail. Limits: eager Collection only, in-memory adapter. 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 per-election cells in `browser-coordinator.test.ts` (fixed tests; no oracle owner (tracked in #2264)) 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 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) (fixed histories and the source-commit scheduler-order test live in `packages/browser-db-sqlite-persistence/tests/browser-coordinator.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), [persisted sync problems](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts), [persisted reload replacement](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.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 persisted sync-problem oracle (`packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts`) owns how the wrapper reports failures of its own remote work and to which sync run: a later successful remote request ends the wrapper's remote failures (maintainer decision: a coarse clear, by any remote request that succeeds, the same one repeated or another; 8 histories failed before it, and a wrapper without the clear fails 7); 35 histories over eight sites (coordinator release, the adapter's own `unloadSubset` throw or reject, scoped refresh ensure and release, rotated reacquisition through a routed or single-process coordinator, and a reload's cleanup release), each crossed with ready or loading and with the current run or a run that `reload()` retired before or after the new run's `markReady()`. A remote failure is a ready Collection's failure reason and never a crash; before the first ready it is only logged; an `unloadSubset` breach surfaces uncaught; a retired run's failures change nothing in the new run and never reject its cleanup. HEAD failed 29 histories; mutants that crash, remove the generation fence, or let a cleanup release reject each fail. Limits: controlled adapter and coordinator doubles; no Browser or Electron follower routing; no coordinator retry queue. The persisted reload-replacement oracle (`packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts`) checks over 40 histories that the wrapper forwards `begin({ snapshot })` and `truncate({ markReady })`, that its restore and metadata commits replace nothing and do not complete a reload, and that durable rows equal visible rows after a replacement. HEAD's wrapper failed 15; a dropped snapshot, a non-durable snapshot, dropped truncate options, and a restore marked as a snapshot each fail. Limits: eager Collection only, in-memory adapter. 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 per-election cells in `browser-coordinator.test.ts` (fixed tests; no oracle owner (tracked in #2264)) 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 mutant 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 grammars 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 fixed as failing under the known limit [#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 grammar 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 fixed 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 grammar 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), [Preact conformance](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/conformance.test.ts), [Preact binding witnesses](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/useLiveQuery.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 Preact/Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. The Preact driver observes each result at commit through a layout-effect probe under an error boundary, because Preact runs passive effects after paint and `act` drains only those it scheduled; it has no Suspense, server-snapshot, or DbClient witness. Its binding witnesses check that a write between render and subscription publishes and that an equal `queryKey` or structurally equal query keeps its live-query Collection. Subscribing in a passive effect instead of a layout effect survives both, as allowed scheduling freedom. The query-identity laws (1.0, maintainer decisions 2026-10-22 and 2026-10-23) run through every live-query driver and the React, Preact, Vue, and Svelte infinite drivers, branching on each driver's public `comparesQueryIdentity` policy. In a binding that compares identity (React and Preact), a query without a nonempty `queryKey` whose IR has no stable hash throws `UnhashableQueryIRError` at mount and at recompilation; an empty key counts as none, and a nonempty key runs the query. React no longer accepts dependency arrays. Such a binding claims each source Collection ID it compiled, so A(x) -> C(y) -> A(x) is legal while a later B(x) throws `SourceCollectionIdReuseError`. A pairwise model judges 32 enumerated histories over A(x), B(x), C(y), and a disabled step, with declared reach for a swap after an intervening query, a swap after a disabled step, and a revisit after an intervening query. Reach labels name the steps between the claim and its test, so a direct swap does not count; removing the C(y) step fails reach. Claims that keep only the latest query, or reset at a disabled step, fail it. React retains two shared-suite registrations, but both now call the same derived-identity API; the registration still named `react (dependency list)` does not establish a second production path. The earlier five failures from skipping a dependency-list check at first compile were evidence for the removed API. A nonempty `queryKey` skips the claim: React and Preact witnesses swap a same-ID source under a changed key, and a key reader that ignores keys fails both. One query over two same-ID objects uses overlapping but different rows; an engine mutant that resolves each source through the Collection-ID map, so the last object serves both join sides, fails the live and infinite scenarios. A Svelte hydration witness checks that a `DbClient`-backed query with a dependency list hydrates `queryKey: []` exactly as it hydrates an omitted key. The infinite suite runs the same pairwise grammar over A(x), B(x), and C(y), 18 histories with declared reach for a swap and a revisit after an intervening query; it has no disabled or page-shape step, because one infinite handle changes either its query or its page shape. A new binding starts unclaimed. The former React dependency-list/`DbClient` witness is historical; it does not establish current reuse or hydration behavior. The shared conformance rejection scenarios currently check that, after a rejected rerender, two reads each throw or hold no rows. Reactive drivers accept an unhashable query, as described below. React unmounts a tree whose render throws. Preact keeps such a component mounted, so its driver mounts the hook under an error boundary, which unmounts it; a Preact component with no boundary keeps its last committed rows and is not witnessed here. It guards a future driver whose binding stays mounted; the `DbClient`-backed Svelte binding, which stays mounted, is checked field by field in fixed [descriptor release tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/descriptor-query-release.svelte.test.ts) (no oracle owner; tracked in #2264). A reactive binding (Vue, Solid, Angular, Svelte without a `DbClient`) runs the same histories and must publish the current input's rows: the unhashable query runs and recompiles when a captured value changes, and B(x) shows B's rows, never A's. One query over two same-ID objects is likewise rejected by a comparing binding and read through an inner join by a reactive one. The same mount-and-recompile split is not yet generated for `DbClient`-backed Svelte or React hooks beyond the receiving witnesses below. The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) owns the release law for `DbClient` descriptors: a rejected query releases every sync start it deferred. React exercises applicable causes (source-ID collision, unhashable IR, a descriptor factory that throws, a query rejected during compilation, a single-result infinite query, and a render abandoned by a sibling throw or suspension) through live, infinite, and Suspense hooks with derived identity or changed explicit `queryKey`, plus ordinary-hook configurations that disable reclamation, at initial mounts or rerenders and through four readers. Explicit keys bypass source-ID and derived-IR rejection; single-result rejection applies only to infinite hooks, and abandoned-after-hook histories exclude Suspense because its preload already starts the sources. Svelte crosses collision, unhashable, factory, compile, and abandoned-setup causes with eager and lazy sources and three readers, and its collision history reads every public result field separately after the rejection; dropping the guard from one getter, such as `isLoadingError`, fails all six collision cells. The earlier dependency-list Suspense cache-claim failures are historical. Current ordinary [source-ID tests](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse.test.tsx) check separate client-scoped Suspense hooks and an initial suspended retry with different same-ID sources; they use the current derived-identity API. These fixed histories are supplemental tests, not a dimension of the claim grammar: that grammar's disabled step has no Suspense form, because `useLiveSuspenseQuery` rejects a disabled query. React ordinary/infinite hooks and Svelte's compiled query release at their render boundaries. The current React Suspense rejection histories fail within its shared ordinary-hook preparation; removing only the outer Suspense wrapper's catch is an unreached mutant, not a demonstrated kill. The former dependency-array SSR history was removed because its API is absent from the RC; it had executed no cells in the preceding grammar. Routing Svelte's getters around the compiled query fails its collision cells; `preload()` overrides a deferral, so those cells pass without the release by design. Declared reach: each enumerated release history asserts that the rejected query ran the shared descriptor's factory once before the reader, and a history that binds the failing descriptor first fails that check. A render that never commits (React: a sibling throws after the hook returns, or the render suspends; Svelte: setup throws after the hook returns) releases its deferral when garbage collection reclaims the live query it built (maintainer decision 2026-10-23), or after the unsubscribed GC floor when the hook's config disables that reclaim (`gcTime` 0 or `Infinity`, or `startSync: false`); both release grammars add these causes, React adds the three config paths and Svelte a `gcTime: 0` setup, and waiting only for the reclaim fails those cells; removing the reclaim release fails exactly them, and releasing at the end of render instead fails the descriptor binding owner's no-work-before-commit law. Known limit, owner the maintainer: the reclaim follows the 50 ms unsubscribed GC floor, so a render whose commit React holds past that floor releases its deferral, and its source may start, before it commits; that render then subscribes to a cleaned-up live query, which restarts. No history holds a commit past the floor, which needs a controlled React scheduler or fake timers; this joins the existing held-commit open cell in the infinite-query publication row. A keyed step records no claim and checks none (maintainer decision 2026-10-23): for a comparing binding the claim grammar adds keyed steps AK and BK over the same-ID objects, 108 histories with declared reach for a claim that crosses a keyed step and a keyed step after a claim; a keyed step that claims, or that resets earlier claims, fails it for the current React derived-identity path and for Preact. Each controllable driver records a publication log (`publication-log.ts`) at the end of each synchronous turn, plus the value on screen at the first paint after each `setParam`, once the framework has flushed and before any later task (maintainer decision 2026-10-23). After a same-ID swap, a reactive binding's every logged value holds only the new source's rows or none: a binding that keeps the old rows published until a later task fails it in Vue and Svelte. A value replaced within one synchronous turn is not logged, because the browser never paints it; Solid and Svelte publish the previous query's rows once inside the swap's own turn, for any input change, and pass. Accepted design (maintainer decision 2026-10-23): React claims source IDs during render, so a concurrent render that is abandoned before commit keeps its claim, as the React oracle states for a render that suspends, because that render's live query may still own the source until cleanup. Same-ID source objects stay legal; the claim, not `createCollection`, rejects a swap. Trace: committed C(y); a transition render reads B(x) and is interrupted; an urgent render reads A(x) and throws `SourceCollectionIdReuseError`. The abandoned-transition history has no witness. Adapter-specific `queryKey` change semantics stay with each adapter's own tests; React's infinite hook also has a focused witness that a `queryKey` array changed in place recompiles, because only React hands the caller's array to the shared window binding. 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, Preact, 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 Solid Suspense witness also checks that a source's `reportError()` and `fail()` after readiness keep the boundary's rows shown and the query `ready`; a hook whose status follows the health tree fails it. A resource that suspends again on a tree change is not observable at that cut, because its refetch over a ready query settles before a fallback renders. 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. | -| Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. | +| Backend Collection E2E | [shared provider suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites); [Electric registration](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/electric.e2e.test.ts); [Query registration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/e2e/query.e2e.test.ts); [TrailBase registration](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/e2e/trailbase.e2e.test.ts) | Electric, Query, and TrailBase register reusable predicate, pagination, join, and live-update checks at their provider boundaries; Electric also registers the move suite. These are receiving paths for core query and Collection laws, not framework render cuts. Shared e2e suites follow-up: the suites and `notification-laws-oracle.ts` declare no `owns` (they have no production code in `db-collection-e2e`), and no consumer (`electric.e2e.test.ts`, `query.e2e.test.ts`, `trailbase.e2e.test.ts`, `persisted-collection-conformance-contract.ts`) declares one either, so the gap report counts none of their kills. Owner: this row. Needed: `owns` in each consumer for the adapter entries it judges, which the gap report can run only once a consumer is an oracle-named file under the package's Vitest config; the consumers run under separate e2e configs or host runtimes, not under it. | | Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React driver](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [Vue driver](https://github.com/TanStack/db/blob/main/packages/vue-db/tests/infinite-query-conformance.test.ts), [Svelte driver](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts), [Preact driver](https://github.com/TanStack/db/blob/main/packages/preact-db/tests/infinite-query-conformance.test.ts), [review record](oracle-reviews/dec-03-window-overlap.md) | Four or five ordered source rows, two window-settlement orders, and public snapshots after each settlement, a subscribed observer update, a detached controller read, and explicit recovery. Insertion and removal of the fifth row distinguish both continuation directions while an earlier error remains visible. The detached read follows a source change with no controller subscriber; its preloaded Collection retains the five-row window. The exact overlap uses the exported DB controller in each package realm. Framework hooks expose no controller preload, so this cell does not establish a hook scheduling path; direct hook paging remains in the adjacent shared scenarios. An unsubscribed controller has no notification claim. | | Infinite-query publication | [shared publication laws](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite-oracle.ts), [React render cuts](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx), [Svelte hook tests](https://github.com/TanStack/db/blob/main/packages/svelte-db/tests/useLiveInfiniteQuery.svelte.test.ts), [review record](oracle-reviews/2026-10-05-infinite-first-paint.md) | Each driver records every value its framework published: React layout commits, Vue pre-flushes, and the Svelte construction value plus pre-effect runs. The shared suite checks that a synchronous source shows its ready first page on the first value, for a query callback and for a supplied collection that has not started, that every ready value in a fixed-source, fixed-query scenario equals the source prefix for its own page count, including an equal dependency replacement that keeps page depth, and that a mount requests an on-demand first window once, for a query callback and for a supplied collection whose finite window is wider than or equal to the one the hook needs, judged by the rows the on-demand source holds after mount. Under deferred acquisition, an on-demand first value is either the ready first page or a value with no rows, by framework: React and Svelte publish before the hook subscribes, Vue after. `preloaded-collection-first-paint` checks the documented route-loader pattern: a limited live query preloaded before mount shows its ready first page on the first value in every framework and asks the source for nothing more; a preload that does not count as demand never settles there. Pre-commit work has no shared form, because Vue setup is its commit. The React driver owns renders that never commit, StrictMode double renders (one first-window request on React 19), retries after GC, a mounted replacement whose startup error must reach the error boundary, and a collection kept from an abandoned render that enters terminal error before its query is selected again, each as under `useLiveQuery`. The React source ID reuse oracle drives the infinite hook too. Svelte owns construction and superseded recomputes before the subscribing effect. In both, the hook acquires an on-demand source as often as `useLiveQuery` does under the same history, for either input form, direct or behind a live-query Collection. On-demand requests from renders that never commit are now owned by the live-query deferred acquisition oracle; the React render-cut oracle checks that an abandoned render makes no acquisition. Open cells, owned here: React 18 StrictMode, where neither hook keeps refs across the double render; a React commit held after the 50 ms unsubscribed GC floor, where a controlled-premise controller witness shows no non-ready value but no React scheduling witness exists; ready values in scenarios that write the source or replace the query, which need a per-step source model; a join of eager and on-demand sources, which needs a zero-acquisition check before commit; a supplied collection with no `.limit`, which sends one unbounded request on `main` too, because the controller cannot narrow it before the source request; and Svelte `state_unsafe_mutation` when a start inside `$derived.by` synchronously writes rows a peer is waiting for, shared with Svelte's `useLiveQuery`, which a two-component probe reproduces and the deferred-acquisition follow-up removes. | -| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-oracle.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph-oracle.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts), [murmur stream](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor-oracle.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, structural object keys in code-unit order (the flat-value, identity and consolidation grammars include keys that tie under locale collation), murmur byte framing against a byte-at-a-time reference (block mixing, the finalizer and symbol framing have no owner there), Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this lane. The utility owner additionally generates widths 6–12 of ambiguous object-valued Sets. An independent count/capacity model judges equal reordered bags, late unmatched prototypes, multiplicity deficits, and a generic plain/class bridge that needs reassignment. Row comparison requires exact shape/value counts; generic equality permits keyed class/plain/null bridges. General and row comparison paths run in both directions, with acyclic members or links back to the Set. Separate cubic scalar-field-read and scoped native Map/Set-operation ceilings reject repeated-comparison and cached-candidate factorial searches without a timing assertion. Empty and singleton neighbors use the same model. Thirteen declared states each reach 200 histories. A paired-input lane adds widths 1,024–3,072, distinct/interchangeable members, and root-linked cycles. Its eight states each reach 20 histories; linear scalar-read and native-operation ceilings protect this equal corresponding-member family. Arbitrary unordered inputs retain the polynomial fallback; arbitrary graph topology and nested-value cost remain outside these bounded work laws. The deep-equality owner also pins that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also pins that `sameRowValue` preserves record prototypes (maintainer decision 2026-11-03), while exported `deepEquals` retains its established keyed-class/plain/null/cross-realm bridge, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner checks equal-hash consequences for independently constructed flat values. Distinct type identities are checked by the hash-identity owner through `equalHashValues`, including forced digest collisions; distinct 32-bit digests are not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; pinned cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` pins how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | +| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-oracle.property.test.ts), [hash identity](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-identity-oracle.property.test.ts), [MultiSet consolidation](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph-oracle.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph-oracle.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry-oracle.property.test.ts), [murmur stream](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils-oracle.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor-oracle.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts), [BTree Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts), [LIKE semantics](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/evaluators-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, structural object keys in code-unit order (the flat-value, identity and consolidation grammars include keys that tie under locale collation), murmur byte framing against a byte-at-a-time reference (block mixing, the finalizer and symbol framing have no owner there), Map/group/sort recomputation, expression denotation, custom comparator dispatch/reference identity/index compatibility, LIKE wildcard refinement, compiled output bags, declared value-pair agreement between D2 equality keys and IR equality operands, and Collection collation with exact public scan/index order and one-index reuse across repeated reads. The auto-index cells cross BasicIndex and BTreeIndex; scan cells omit indexes. Both paths cover lexical and numeric `en-US` locale defaults plus an explicit ordinary locale override of a numeric default. An unindexed six-row work cell bounds Collection collation resolution to at most two reads per ordered scan: one index-path check and one scan clause. Other locales and ordered histories are outside this owner. The identity pair grammar covers primitives, signed zero, NaN/invalid Date, valid Date/timestamp, binary/Buffer, Temporal, nested references, symbols, and functions; fixed and sampled pairs do not prove every JavaScript value or hash collision freedom. Ref proxies are expressions rather than literal values in this grammar. The utility owner additionally generates widths 6–12 of ambiguous object-valued Sets. An independent count/capacity model judges equal reordered bags, late unmatched prototypes, multiplicity deficits, and a generic plain/class bridge that needs reassignment. Row comparison requires exact shape/value counts; generic equality permits keyed class/plain/null bridges. General and row comparison paths run in both directions, with acyclic members or links back to the Set. Separate cubic scalar-field-read and scoped native Map/Set-operation ceilings reject repeated-comparison and cached-candidate factorial searches without a timing assertion. Empty and singleton neighbors use the same model. Thirteen declared states each reach 200 histories. A paired-input lane adds widths 1,024–3,072, distinct/interchangeable members, and root-linked cycles. Its eight states each reach 20 histories; linear scalar-read and native-operation ceilings protect this equal corresponding-member family. Arbitrary unordered inputs retain the polynomial fallback; arbitrary graph topology and nested-value cost remain outside these bounded work laws. The deep-equality owner also checks that `deepEquals` ignores Map and Set insertion order, RegExp `lastIndex`, array holes, and typed-array class, the rules draft equality keeps. It also checks that `sameRowValue` preserves record prototypes (maintainer decision 2026-11-03), while exported `deepEquals` retains its established keyed-class/plain/null/cross-realm bridge, that a class instance without enumerable keys equals only itself, that URLs compare by `href`, and that typed-array elements compare like numbers. The hash-values owner checks equal-hash consequences for independently constructed flat values. Distinct type identities are checked by the hash-identity owner through `equalHashValues`, including forced digest collisions; distinct 32-bit digests are not promised. The hash-identity owner checks `hash` and `equalHashValues` against one spec-level identity model: primitives with signed zero, NaN, and bigint; reference leaves (functions, registered handles, `File`, binary over 128 bytes); Date, binary, Temporal, and RegExp headers; array length and holes; Map and Set insertion order; and object keys in any order, for acyclic values and for cycles through arrays, Map values, Sets, and plain objects. Map/Set order sensitivity is pinned current behavior, not a promise. Arrays from another realm, RegExps with a `NaN` `lastIndex`, getters, proxies, other cycle carriers, and pairs that differ only in a back-edge target are outside its grammar; fixed cases keep equality's cross-realm length check and strict RegExp field comparison. `hash-work.test.ts` fixes how visited values and array/RegExp headers count toward the structural work cap, and that equality copies a shared Map's entries once per distinct pair ([code-weight hash dispatch review](oracle-reviews/code-weight-hash-dispatch.md)). Custom string indexes do not optimize ordinary ranges, and custom cursors remain unsupported. The LIKE owner covers boolean string matching and bounded work, not nullish three-valued logic or a general Unicode collation contract. Unsupported composite cursors reject. The MultiSet consolidation owner checks keyed, single-type, and structural identity, signed zero and NaN in keyed and single-number data, the retained first record, zero-sum removal, and input record contents. It does not assert output order. Its keyed grammar includes cross-type key and value collisions, non-finite numeric keys, the `\|` delimiter, symbol and function identity, and a direct-object/object-tuple delimiter control from #1948. Eight named collision witnesses and both generated campaigns are RED on `c879ba6d` and GREEN with the repair ([#1948 review record](oracle-reviews/issue-1948-keyed-consolidation.md)); the [code-weight consolidation review](oracle-reviews/code-weight-multiset-consolidation.md) records the earlier exclusion. The keyed reference uses direct value/reference equality, including SameValueZero for numeric keys. The unkeyed fallback keeps its original key and value domains. The direct `MultiSet` owner does not prove which `@tanstack/db` query shapes produce primitive keyed values or mixed-type source keys; that needs a live-query production witness. The index owner enforces one invalid-comparator law across both BasicIndex and BTreeIndex: for each comparator, accepted numeric prefix (including the empty prefix), and probe operation (add, update, eq/range lookup, take, build), the operation throws exactly when it receives a `NaN` or non-number result and never when every comparison is valid. A `signed infinity` comparator is the valid control, and a custom collation `compare` supplied through `compareOptions` is checked the same way. Two BTree Map split histories check inserted-key reads, exact whole-range payloads/order, size, and extrema, proving split placement follows the insertion index without a post-mutation comparison. A rejected index add or remove leaves the index refining its accepted rows; update and build are not atomic. At the Collection boundary, both index types and both write paths (optimistic insert, sync commit) crash the collection: status becomes `error` and the next mutation throws, so a usable collection never holds rows its subscribers were not told about. Open: a sync source can still commit writes on a collection in `error` state, which stores unpublished rows; this is existing `markError` behavior and needs a lifecycle-owner witness. Comparator transitivity is outside this evidence. | | Mixed-kind ordered prefixes | [mixed-kind public-order oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/mixed-index-order-oracle.test.ts) | A separate unindexed ready Collection computes the public ordered two-row prefix for the same keyed rows as an eager BasicIndex or BTreeIndex Collection. Five histories cross number/bigint, boolean/number, object/string, and array/object values, with insert, update, and delete transitions into and out of mixed domains. Ascending and descending reads use one ascending physical index, so descending checks exercise ReverseIndex. After preload and after both source copies commit each step, exact direct and live-query IDs must match the scan. Same-kind checkpoints verify that safe indexes still serve ordered reads. The original type-name index order failed eight ascending public-prefix checks. A second numeric source Map models a finite on-demand provider: a bigint enters and leaves the visible prefix, deletion requires an uninstalled row, and an offset window reaches a cursor after the retained BTree index loses ordered-plan eligibility. Public IDs match the independent provider order at each settled window; controls withholding either requested row fail those assertions. This owner does not define a global precedence among unlike values, cover in-place value mutation or arbitrary mixed-kind pairs, or prove that every retained on-demand traversal is safe after its source domain changes. | -| WHERE predicate publication | [WHERE predicate publication oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts), [`in` evaluator](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts) ([review](oracle-reviews/perf-in-constant-set.md)) | An independent Kleene evaluator judges `eq`/`not`/`and`/`or` predicates over strings, a normalization-prefixed string, booleans, numbers, `NaN`, a valid Date, `null`, a missing field, and virtual fields. Snapshot histories with pending optimistic inserts compare a live query, direct subscribers with and without initial state, and `currentStateAsChanges`; change histories apply multi-key sync transactions, including reinsertion of a key deleted earlier, and compare every consumer's key set after each commit, on scan and `BasicIndex` paths. Peer subscribers share one field with different literals, plus one on a virtual field, so every published change must reach exactly the subscribers whose predicate it can satisfy; routing mutants that ignored the previous value, ignored the field, delivered a change twice, or routed while stale published rows awaited reconciliation fail here. Fixed witnesses cover a layout-only publication reaching a filtered subscriber as one empty batch, a filtered subscriber's empty Collection-readiness batch, retraction of a vanished row after eager cleanup and restart, and the new row plus exact subscriber update payload when a same-key row stays TRUE. A stale-live-value mutant survives the generated membership checks but fails the payload witness. A focused [property-visibility test](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-prefilter-property-visibility.test.ts) compares the public unindexed snapshot with the enriched-row contract for inherited, non-enumerable, enumerable-own, and nested getter paths; the pre-fix stored-row shortcut failed three of its four controls. Other property-descriptor and stateful-getter histories remain outside those fixed controls. Hostile mutants for FALSE-for-UNKNOWN `eq`, `or` or number-literal subscription prefilters, a prefilter that ignores the previous value, a skipped readiness batch, a skip while stale published rows await reconciliation, and an unindexed snapshot scan that reads only synced rows while an optimistic insert or delete changes visibility passed the prior `@tanstack/db` suite and fail here. Change routing withholds a dropped row from an `eq` subscription before its sent key could be recorded, so a mutant that records dropped rows as sent is equivalent for routed predicates in this oracle. A focused `collection-subscription.test.ts` witness checks that a dropped insert or update, alone or beside a matching change, cannot advance a limited subscription's page offset under a routed `eq` and an unrouted `or` predicate; the unrouted cases beside a matching change kill that mutant, and the unrouted `alone` case does not. A loose-equality prefilter is an equivalent mutant: it only skips less. Skipping during truncate replay also survives; the replay's baseline diff re-derives the same retraction, so the guard keeps the prior dataflow without its own witness. Generated cleanup and restart histories for filtered subscribers remain open for the lifecycle publication owner. No-op updates, other comparison operators, Temporal and binary operands, joins, ordering, optimistic updates, and truncate are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. Change routing was later removed in favor of pooled live queries; the routing mutants above are historical, and the same peer laws now check plain subscription dispatch. The unindexed snapshot prefilter was restored for compiled queries as one stored-row `eq` test on a string or boolean literal. Its property-visibility test also covers the reverse direction: a stored row with an inherited or non-enumerable field must still match `isUndefined` on that field, which a scan that evaluated the full predicate on stored rows fails. The `in` evaluator owner checks `in` against an independent linear reference over Dates, `NaN`, `-0`, BigInt, bytes, Temporal values, objects and nullish items, for constant and row-supplied lists. | -| Joined result keys | [Joined result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/join-result-key-oracle.property.test.ts) | A nested-loop model recomputes the pair set of an inner, left, or full join over source keys drawn from plain, comma-bearing, bracket-bearing, and quoted strings, numbers beside the strings that print the same, both infinities, and `NaN`. After preload and each synced group change, the published rows must equal the model's pairs and the result key count must equal the row count. The comma-joined key encoding fails its two pinned histories and both campaigns; plain `JSON.stringify`, which prints infinities as the missing-side `null`, fails the pinned infinity history and both campaigns. A pinned history in which a `NaN`-keyed pair leaves and re-forms fails when the join index compares source-key prefixes with `===`. Joins over subqueries, more than two sources, custom `getKey`, and optimistic mutations are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | -| D2 Index storage | [Index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/index-refinement-oracle.property.test.ts) | A plain-`Map` model sums multiplicities under a type-and-value identity in which `NaN` equals `NaN` and `-0` equals `0`. Histories of up to twelve additions to two keys mix prefixed arrays with `NaN`, `0`, `-0`, `1`, `'1'`, `1n`, and `'a'` prefixes and unprefixed values, with cancellation and reappearance. After each addition, `get` and `has` must equal the model. Each step also checks that `get` returns what `getIterator` yields, in its order, since the two walk the storage separately. Comparing prefixes with `===`, which disagrees with the `Map` that holds them, fails three pinned histories and both campaigns; the `-0` control passes under both. Every history also runs with `prefixIdentity`, where values under one key with the same prefix are one value; mutants that merge unprefixed values or ignore the option in a prefix map fail. The [structural identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/structural-identity-oracle.property.test.ts) checks that `structuralIdentity` agrees with D2 value identity for equal representations (array subclasses, class instances, null prototypes, key order, non-enumerable symbols) and independent values, including separator-bearing strings and keys, enumerable symbol keys, Dates, RegExps with numeric `lastIndex`, Maps and Sets with zero to two entries (the second Set member is a fresh tagged array, so primitive deduplication and unwrapped reordering are outside this lane), arrays with an optional enumerable `note` field, binary values, holes, deep nesting, and a cyclic value; an independent normal-form model decides equality and is checked against `equalHashValues`. The model expands `deep` shorthand into nested arrays. A pinned equal `deep`/array pair and adjacent-depth control guard the model boundary exposed by seed 1158727847/path 255. The equal-pair grammar crosses same-spec values with explicit-array/`deep` equivalent specs; the independent-pair grammar uses two specs. Both run fixed and fresh campaigns with guarded one-case seed/path replay. Pinned same-description symbols and equal-body Map/Set/object pairs expose collisions that random independent pairs rarely reach. Mutants that stringify symbols, collapse those three object-kind tags, drop a string's or key's length or an array's length, or omit Date and RegExp headers fail it. A first-entry-only Map/Set mutant and an array-identity mutant that ignores enumerable non-index fields fail the pinned comparisons; lowering the depth cap is equivalent, because the cap only bounds work. Open: reference-valued RegExp `lastIndex` can give D2-unequal values one defined identity and merge `groupBy` contributions ([#2192](https://github.com/TanStack/db/issues/2192)); the structural-identity and incrementalization owners need a production repair and receiving witness. Temporal values, other opaque reference values, large binary values, getters/proxies, and mutation between observations are outside this generated grammar. groupBy's use of it is owned by the incrementalization law: its string-extrema lane fails a string identity without a length prefix, and a focused groupBy witness fails when the group key is left out of the identity. Compaction, presence tracking, and structural payloads are outside this owner; a focused test checks that an index built without presence (as reduce builds its indexes) throws when presence is read; the [reduce fast-path oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts) compares the fast path's emitted entries one by one with the general path's, over keys that move between the paths, hash-merged outputs, -0, and zero and negative multiplicities, while an unchanged `NaN` output is owned by a focused reduce test; the join operator tests and incrementalization law own operator behavior. | -| Pooled live queries | [pooled live query oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-oracle.property.test.ts) | An independent `eq` model (nullish UNKNOWN, Date as timestamp, `NaN` equal to `NaN`, `-0` equal to `0`) gives membership; a live-query Collection compiled for the same query gives order, values, and status. Histories mix sync transactions, optimistic writes confirmed or rolled back, peer mounts and unmounts, and source cleanup and restart, including cleanup while a write is pending and a mount between cleanup and restart. Wholesale observers must match after every step, granular changes must match a reference observer by type, key, value, and previous value and replay to the model's rows, the layout revision must advance when keys reorder, and observing must never build the live-query Collection. Peers may add a residual `not(eq(...))` conjunct, which the model evaluates itself; a view that ignores it, reports an unfiltered `entries()`, or delivers a row entering or leaving it as an update fails it. Other residual operators use the same compiler evaluator but are not generated. Peers may also order their rows by `id` or by a `g` that can be null, with explicit `nulls`; order comes from the reference. A comparator that ignores direction or `nulls`, an order left out of the partition key, and key order instead of the query's order each fail the pinned reorder history and both campaigns. A partition that ignores a row's previous group, orders by arrival, skips normalization, skips the initial snapshot, follows the source's status after cleanup, or never terminates fails it. Two shared conformance scenarios (`eq-filter-rows`, `eq-filter-peers`) run the pooled path under React, Vue, Solid, Svelte, and Angular; a partition that ignores a row's previous group fails both under every adapter, and `eq-filter-peers` checks through `subscriberCount` that each adapter shares one source subscription per partition while mounted and releases every source subscription after the final framework unmount. The [resource-lifetime oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts) compares when a pooled query and a live-query Collection release their sources across `gcTime` 1, 100, 0, and `Infinity`, with and without a subscriber, and with no `gcTime`, where both use the live-query Collection default, checks that a partition waits for its views' longest `gcTime`, checks that a view resubscribed after its partition released follows later source writes as its live-query Collection does, including a view with a residual conjunct, and checks that a partition that subscribes again still serves new mounts from one source subscription. A generated cleanup-admission lane (`pooled-live-query.cleanup-admission`) crosses 2–3 views of one partition, observed or not, read by a subscribed live query or not, and view cleanups with or without `dependents`; it checks the model's rejection rule and rows and status against live-query Collections. A view that gives up its partition share when its cleanup rejects fails it. A view whose own cleanup fulfilled must show `cleaned-up` with no rows (law 14, maintainer decision), checked against the model and the live-query Collection; a view that keeps its source's status or its group's rows fails it. Further focused witnesses check that a partition keeps groups only for values that have rows or watchers, that a detached reader never sees a dropped group's revision reused, keep a pooled query's public Collection live while the view is observed, accept it as a query source, and check that a pooled query turns terminal when source cleanup starts while the adapter's cleanup is still pending, as a live-query Collection does. A detached wholesale reader of every view, kept after its peer unmounts, must match the live-query Collection at every checkpoint, and generated histories cross the dependents cascade with readers on watched and unwatched groups that hold rows (declared reach); a cleanup that empties groups without advancing their revisions, or advances only watched groups, fails it. Every view and its live-query Collection also carry an app status listener that throws; each listener gets its own call and the throw is reported uncaught, so a view that lets a listener's throw stop the others or reject the source's cleanup fails it. Generated callback-boundary histories also compare ordinary compiled and pooled core observers across return/throw, Error/undefined, zero through three healthy peers, callback position before/after peers, and `gcTime` 1–3. Failed granular bootstrap retains no unreturned ownership after grace; a held cascade cleanup (`cleanup({ dependents: true })`) gives every admitted wholesale observer one terminal `cleaned-up` snapshot/callback despite a sibling throw, and an ended partition stops following its source's health, and preserves the exact failure at settlement. Original production and independent rollback-only or all-peer-only repairs fail the respective ownership or cleanup-start comparisons. These controlled core receiving paths do not establish framework schedules, arbitrary callback programs or provider shutdown. A pinned oracle witness checks that an `eq` path whose getter throws excludes the row on both paths; a pooled path that rethrows fails it. The pooled oracle's grammar never releases a partition, so release and resubscribe histories rely on these fixed witnesses; a fixed delay, a missing unsubscribed floor, a last-`gcTime`-wins rule, and releasing at `gcTime` 0 each fail it. Removing the partition's final-listener release survives the mounted row checks but fails the shared framework unmount checkpoint and core release-timing owner. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. The shared adapter conformance suite checks the opt-in boundary in every framework: queries without `pool` keep a source subscription each in the function, config, and parameterized forms, and a pooled parameterized query follows its parameter between pool groups beside an unchanged peer. Pooling every query by default, a pooled identity that ignores literal values (React then reuses the old query), and an Angular `params` form that drops `pool` each fail it. `pooled-param-change` also reads each driver's publication log: every value published after a switch holds only the new group's rows. Each driver also logs its keyed `state` map at the same points, and the law applies to it. A pooled view that serves the previous view's rows until a later task replaces them passes every final-state check and fails only the log, in all six drivers. A view that sends the previous rows as its first change set fails the `state` log in Vue; Solid rebuilds its map from a snapshot within the same turn, and Angular builds its map from `entries()`, so that mutant cannot reach them. The `invalid-pool` scenario requires `InvalidPoolOptionError` for `true` and for `false` (the value adapters pass to say a hook cannot pool), alone, beside `gcTime`, beside `queryKey`, on a parameterized query, and on a disabled config or parameterized query, in all six drivers; a check in only one of the three readers of `pool` fails it in four of them, and Angular's `params` form failed the `false` and disabled cases until it checked its pool first. The shared `grouping-operator-switch` scenario re-renders a grouped query without `groupKeys()`, and `compile-error-after-rerender` re-renders to a select that mixes a source field with an aggregate; both require the error and no rows after it. A binding that keeps its last compiled query when compilation throws fails both in Solid, Svelte, and Angular; one that throws once and then serves that query fails the no-rows check in Solid and Svelte, and cannot occur in Angular, whose `computed` rethrows a cached error until an input changes. In `grouping-operator-switch`, React and Preact kept the grouped query before query identity held operator code, and Vue served the previous rows before its compile error stopped reaching readers through a throwing computed (also on `main` at `5649a07d4`). Its declared reach also requires successful wholesale, granular and detached observer comparisons on the same-copy pooled view, whose authoritative clocks do not require a separate layout subscription channel. | -| Live-query deferred acquisition | [deferred acquisition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts), [React Suspense warm-cache witness](https://github.com/TanStack/db/blob/main/packages/react-db/tests/suspense-warm-cache.test.tsx) | An independent model of the law that a live-query Collection starts no provider work before a subscriber or a preload in its current sync run crosses seven source states (eager or on-demand, idle, already started or render-deferred, and an on-demand source that already holds the rows), an unordered query and an ordered window, one or two live-query levels, and eleven histories: start and read, subscribe or preload after a start, subscribe or preload without one, deferred subscription and resumption with or without a start, cleanup before a subscriber, peer start, source truncate, and restart after a run that had one. After every command it checks source sync-run starts, acquisition attempts, readiness when the call returns (the synchronous observation cut), settled readiness, and rows. A pooled block checks that building a pooled view starts no idle eager source until a view subscribes or preloads, and eight DbClient histories check that a preload answered by a pending or settled stream still resumes acquisition across eager/on-demand idle and render-deferred sources. The React streaming witness receives the on-demand render-deferral premise, starts local acquisition before the stream settles, and retains server rows until browser acquisition completes. Mutants that never defer, keep the subscriber-or-preload fact across cleanup, ignore preload, never resume a builder's subscriptions, let a deferring subscriber start a base source, build a pooled partition without deferral, never resume it, mark a preload one microtask late, or drop the stream's preload mark each fail it. The React witness checks that `useLiveSuspenseQuery` over a source that already holds the rows renders without the fallback, for eager and on-demand sources and both shapes; creating the Suspense collection started, or not preloading it in render, fails it. A later review added `peer` and `truncate` commands: a peer that starts an idle on-demand source, or a truncate on a running one, made the live query acquire before any subscriber, and both guards are killed separately. Pinned blocks check that a subscriber surviving cleanup is served after a restart while a surviving deferring subscriber is not, and that `toArrayWhenReady()` and `stateWhenReady()` count as a preload. A pinned block checks that a deferring live query is not counted in its source's `subscriberCount` or `subscribers:change` until its first subscriber, while still keeping the source from garbage collection; a Query Collection test checks that it causes no refetch. Another pinned block checks that a preload or subscriber requested by an inner live query's status handler, while the outer query creates its subscription to it, still resumes acquisition. Solid's Suspense receiving witnesses check that a resource whose preload settles first retains its live-query Collection while its sibling remains pending past the unsubscribed floor, then releases that temporary owner on both commit and abandonment, and that a preload settling after its owner unmounts cannot publish an error through the abandoned result. Open: source failure and asynchronous loads before resumption, which the subscription lifecycle oracle owns once acquisition resumes; a resume that throws, which leaves later sources unstarted only for a live query that is already in error; asynchronous sources; and arbitrary Suspense schedules with a `DbClient` beyond the bounded abandoned-render and streaming handoffs recorded above. | +| WHERE predicate publication | [WHERE predicate publication oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-predicate-publication-oracle.property.test.ts), [`in` evaluator](https://github.com/TanStack/db/blob/main/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts) ([review](oracle-reviews/perf-in-constant-set.md)) | An independent Kleene evaluator judges `eq`/`not`/`and`/`or` predicates over strings, a normalization-prefixed string, booleans, numbers, `NaN`, a valid Date, `null`, a missing field, and virtual fields. Snapshot histories with pending optimistic inserts compare a live query, direct subscribers with and without initial state, and `currentStateAsChanges`; change histories apply multi-key sync transactions, including reinsertion of a key deleted earlier, and compare every consumer's key set after each commit, on scan and `BasicIndex` paths. Peer subscribers share one field with different literals, plus one on a virtual field, so every published change must reach exactly the subscribers whose predicate it can satisfy; routing mutants that ignored the previous value, ignored the field, delivered a change twice, or routed while stale published rows awaited reconciliation fail here. Fixed witnesses cover a layout-only publication reaching a filtered subscriber as one empty batch, a filtered subscriber's empty Collection-readiness batch, retraction of a vanished row after eager cleanup and restart, and the new row plus exact subscriber update payload when a same-key row stays TRUE. A stale-live-value mutant survives the generated membership checks but fails the payload witness. A focused [property-visibility test](https://github.com/TanStack/db/blob/main/packages/db/tests/query/where-prefilter-property-visibility.test.ts) compares the public unindexed snapshot with the enriched-row contract for inherited, non-enumerable, enumerable-own, and nested getter paths; the pre-fix stored-row shortcut failed three of its four controls. Other property-descriptor and stateful-getter histories remain outside those fixed controls. Mutant for FALSE-for-UNKNOWN `eq`, `or` or number-literal subscription prefilters, a prefilter that ignores the previous value, a skipped readiness batch, a skip while stale published rows await reconciliation, and an unindexed snapshot scan that reads only synced rows while an optimistic insert or delete changes visibility passed the prior `@tanstack/db` suite and fail here. Change routing withholds a dropped row from an `eq` subscription before its sent key could be recorded, so a mutant that records dropped rows as sent is equivalent for routed predicates in this oracle. A focused `collection-subscription.test.ts` witness checks that a dropped insert or update, alone or beside a matching change, cannot advance a limited subscription's page offset under a routed `eq` and an unrouted `or` predicate; the unrouted cases beside a matching change kill that mutant, and the unrouted `alone` case does not. A loose-equality prefilter is an equivalent mutant: it only skips less. Skipping during truncate replay also survives; the replay's baseline diff re-derives the same retraction, so the guard keeps the prior dataflow without its own witness. Generated cleanup and restart histories for filtered subscribers remain open for the lifecycle publication owner. No-op updates, other comparison operators, Temporal and binary operands, joins, ordering, optimistic updates, and truncate are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. Change routing was later removed in favor of pooled live queries; the routing mutants above are historical, and the same peer laws now check plain subscription dispatch. The unindexed snapshot prefilter was restored for compiled queries as one stored-row `eq` test on a string or boolean literal. Its property-visibility test also covers the reverse direction: a stored row with an inherited or non-enumerable field must still match `isUndefined` on that field, which a scan that evaluated the full predicate on stored rows fails. The `in` evaluator owner checks `in` against an independent linear reference over Dates, `NaN`, `-0`, BigInt, bytes, Temporal values, objects and nullish items, for constant and row-supplied lists. | +| Joined result keys | [Joined result key oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/join-result-key-oracle.property.test.ts) | A nested-loop model recomputes the pair set of an inner, left, or full join over source keys drawn from plain, comma-bearing, bracket-bearing, and quoted strings, numbers beside the strings that print the same, both infinities, and `NaN`. After preload and each synced group change, the published rows must equal the model's pairs and the result key count must equal the row count. The comma-joined key encoding fails its two fixed histories and both campaigns; plain `JSON.stringify`, which prints infinities as the missing-side `null`, fails the fixed infinity history and both campaigns. A fixed histories in which a `NaN`-keyed pair leaves and re-forms fails when the join index compares source-key prefixes with `===`. Joins over subqueries, more than two sources, custom `getKey`, and optimistic mutations are outside this owner. It runs in `@tanstack/db`'s `test:oracles` campaign. The [2026-09-30 review](https://github.com/TanStack/db/blob/main/docs/contributing/oracle-reviews/2026-09-30-where-predicate-and-join-keys.md) records each ORC outcome. | +| D2 Index storage | [Index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/index-refinement-oracle.property.test.ts) | A plain-`Map` model sums multiplicities under a type-and-value identity in which `NaN` equals `NaN` and `-0` equals `0`. Histories of up to twelve additions to two keys mix prefixed arrays with `NaN`, `0`, `-0`, `1`, `'1'`, `1n`, and `'a'` prefixes and unprefixed values, with cancellation and reappearance. After each addition, `get` and `has` must equal the model. Each step also checks that `get` returns what `getIterator` yields, in its order, since the two walk the storage separately. Comparing prefixes with `===`, which disagrees with the `Map` that holds them, fails three fixed histories and both campaigns; the `-0` control passes under both. Every history also runs with `prefixIdentity`, where values under one key with the same prefix are one value; mutants that merge unprefixed values or ignore the option in a prefix map fail. The [structural identity oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/structural-identity-oracle.property.test.ts) checks that `structuralIdentity` agrees with D2 value identity for equal representations (array subclasses, class instances, null prototypes, key order, non-enumerable symbols) and independent values, including separator-bearing strings and keys, enumerable symbol keys, Dates, RegExps with numeric `lastIndex`, Maps and Sets with zero to two entries (the second Set member is a fresh tagged array, so primitive deduplication and unwrapped reordering are outside this grammar), arrays with an optional enumerable `note` field, binary values, holes, deep nesting, and a cyclic value; an independent normal-form model decides equality and is checked against `equalHashValues`. The model expands `deep` shorthand into nested arrays. A fixed equal `deep`/array pair and adjacent-depth control guard the model boundary exposed by seed 1158727847/path 255. The equal-pair grammar crosses same-spec values with explicit-array/`deep` equivalent specs; the independent-pair grammar uses two specs. Both run fixed and fresh campaigns with guarded one-case seed/path replay. Fixed same-description symbols and equal-body Map/Set/object pairs expose collisions that random independent pairs rarely reach. Mutants that stringify symbols, collapse those three object-kind tags, drop a string's or key's length or an array's length, or omit Date and RegExp headers fail it. A first-entry-only Map/Set mutant and an array-identity mutant that ignores enumerable non-index fields fail the fixed comparisons; lowering the depth cap is equivalent, because the cap only bounds work. Open: reference-valued RegExp `lastIndex` can give D2-unequal values one defined identity and merge `groupBy` contributions ([#2192](https://github.com/TanStack/db/issues/2192)); the structural-identity and incrementalization owners need a production repair and receiving witness. Temporal values, other opaque reference values, large binary values, getters/proxies, and mutation between observations are outside this generated grammar. groupBy's use of it is owned by the incrementalization law: its string-extrema grammar fails a string identity without a length prefix, and a focused groupBy witness fails when the group key is left out of the identity. Compaction, presence tracking, and structural payloads are outside this owner; a focused test checks that an index built without presence (as reduce builds its indexes) throws when presence is read; the [reduce fast-path oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/reduce-fast-path-oracle.property.test.ts) compares the fast path's emitted entries one by one with the general path's, over keys that move between the paths, hash-merged outputs, -0, and zero and negative multiplicities, while an unchanged `NaN` output is owned by a focused reduce test; the join operator tests and incrementalization law own operator behavior. | +| Pooled live queries | [pooled live query oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-oracle.property.test.ts) | An independent `eq` model (nullish UNKNOWN, Date as timestamp, `NaN` equal to `NaN`, `-0` equal to `0`) gives membership; a live-query Collection compiled for the same query gives order, values, and status. Histories mix sync transactions, optimistic writes confirmed or rolled back, peer mounts and unmounts, and source cleanup and restart, including cleanup while a write is pending and a mount between cleanup and restart. Wholesale observers must match after every step, granular changes must match a reference observer by type, key, value, and previous value and replay to the model's rows, the layout revision must advance when keys reorder, and observing must never build the live-query Collection. Peers may add a residual `not(eq(...))` conjunct, which the model evaluates itself; a view that ignores it, reports an unfiltered `entries()`, or delivers a row entering or leaving it as an update fails it. Other residual operators use the same compiler evaluator but are not generated. Peers may also order their rows by `id` or by a `g` that can be null, with explicit `nulls`; order comes from the reference. A comparator that ignores direction or `nulls`, an order left out of the partition key, and key order instead of the query's order each fail the fixed reorder history and both campaigns. A partition that ignores a row's previous group, orders by arrival, skips normalization, skips the initial snapshot, follows the source's status after cleanup, or never terminates fails it. Two shared conformance scenarios (`eq-filter-rows`, `eq-filter-peers`) run the pooled path under React, Vue, Solid, Svelte, and Angular; a partition that ignores a row's previous group fails both under every adapter, and `eq-filter-peers` checks through `subscriberCount` that each adapter shares one source subscription per partition while mounted and releases every source subscription after the final framework unmount. The [resource-lifetime oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts) compares when a pooled query and a live-query Collection release their sources across `gcTime` 1, 100, 0, and `Infinity`, with and without a subscriber, and with no `gcTime`, where both use the live-query Collection default, checks that a partition waits for its views' longest `gcTime`, checks that a view resubscribed after its partition released follows later source writes as its live-query Collection does, including a view with a residual conjunct, and checks that a partition that subscribes again still serves new mounts from one source subscription. A generated cleanup-admission grammar (`pooled-live-query.cleanup-admission`) crosses 2–3 views of one partition, observed or not, read by a subscribed live query or not, and view cleanups with or without `dependents`; it checks the model's rejection rule and rows and status against live-query Collections. A view that gives up its partition share when its cleanup rejects fails it. A view whose own cleanup fulfilled must show `cleaned-up` with no rows (law 14, maintainer decision), checked against the model and the live-query Collection; a view that keeps its source's status or its group's rows fails it. Further focused witnesses check that a partition keeps groups only for values that have rows or watchers, that a detached reader never sees a dropped group's revision reused, keep a pooled query's public Collection live while the view is observed, accept it as a query source, and check that a pooled query turns terminal when source cleanup starts while the adapter's cleanup is still pending, as a live-query Collection does. A detached wholesale reader of every view, kept after its peer unmounts, must match the live-query Collection at every checkpoint, and generated histories cross the dependents cascade with readers on watched and unwatched groups that hold rows (declared reach); a cleanup that empties groups without advancing their revisions, or advances only watched groups, fails it. Every view and its live-query Collection also carry an app status listener that throws; each listener gets its own call and the throw is reported uncaught, so a view that lets a listener's throw stop the others or reject the source's cleanup fails it. Generated callback-boundary histories also compare ordinary compiled and pooled core observers across return/throw, Error/undefined, zero through three healthy peers, callback position before/after peers, and `gcTime` 1–3. Failed granular bootstrap retains no unreturned ownership after grace; a held cascade cleanup (`cleanup({ dependents: true })`) gives every admitted wholesale observer one terminal `cleaned-up` snapshot/callback despite a sibling throw, and an ended partition stops following its source's health, and preserves the exact failure at settlement. Original production and independent rollback-only or all-peer-only repairs fail the respective ownership or cleanup-start comparisons. These controlled core receiving paths do not establish framework schedules, arbitrary callback programs or provider shutdown. A fixed oracle witness checks that an `eq` path whose getter throws excludes the row on both paths; a pooled path that rethrows fails it. The pooled oracle's grammar never releases a partition, so release and resubscribe histories rely on these fixed witnesses; a fixed delay, a missing unsubscribed floor, a last-`gcTime`-wins rule, and releasing at `gcTime` 0 each fail it. Removing the partition's final-listener release survives the mounted row checks but fails the shared framework unmount checkpoint and core release-timing owner. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. The shared adapter conformance suite checks the opt-in boundary in every framework: queries without `pool` keep a source subscription each in the function, config, and parameterized forms, and a pooled parameterized query follows its parameter between pool groups beside an unchanged peer. Pooling every query by default, a pooled identity that ignores literal values (React then reuses the old query), and an Angular `params` form that drops `pool` each fail it. `pooled-param-change` also reads each driver's publication log: every value published after a switch holds only the new group's rows. Each driver also logs its keyed `state` map at the same points, and the law applies to it. A pooled view that serves the previous view's rows until a later task replaces them passes every final-state check and fails only the log, in all six drivers. A view that sends the previous rows as its first change set fails the `state` log in Vue; Solid rebuilds its map from a snapshot within the same turn, and Angular builds its map from `entries()`, so that mutant cannot reach them. The `invalid-pool` scenario requires `InvalidPoolOptionError` for `true` and for `false` (the value adapters pass to say a hook cannot pool), alone, beside `gcTime`, beside `queryKey`, on a parameterized query, and on a disabled config or parameterized query, in all six drivers; a check in only one of the three readers of `pool` fails it in four of them, and Angular's `params` form failed the `false` and disabled cases until it checked its pool first. The shared `grouping-operator-switch` scenario re-renders a grouped query without `groupKeys()`, and `compile-error-after-rerender` re-renders to a select that mixes a source field with an aggregate; both require the error and no rows after it. A binding that keeps its last compiled query when compilation throws fails both in Solid, Svelte, and Angular; one that throws once and then serves that query fails the no-rows check in Solid and Svelte, and cannot occur in Angular, whose `computed` rethrows a cached error until an input changes. In `grouping-operator-switch`, React and Preact kept the grouped query before query identity held operator code, and Vue served the previous rows before its compile error stopped reaching readers through a throwing computed (also on `main` at `5649a07d4`). Its declared reach also requires successful wholesale, granular and detached observer comparisons on the same-copy pooled view, whose authoritative clocks do not require a separate layout subscription channel. | +| Live-query deferred acquisition | [deferred acquisition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts), [React Suspense warm-cache witness](https://github.com/TanStack/db/blob/main/packages/react-db/tests/suspense-warm-cache.test.tsx) | An independent model of the law that a live-query Collection starts no provider work before a subscriber or a preload in its current sync run crosses seven source states (eager or on-demand, idle, already started or render-deferred, and an on-demand source that already holds the rows), an unordered query and an ordered window, one or two live-query levels, and eleven histories: start and read, subscribe or preload after a start, subscribe or preload without one, deferred subscription and resumption with or without a start, cleanup before a subscriber, peer start, source truncate, and restart after a run that had one. After every command it checks source sync-run starts, acquisition attempts, readiness when the call returns (the synchronous observation cut), settled readiness, and rows. A pooled block checks that building a pooled view starts no idle eager source until a view subscribes or preloads, and eight DbClient histories check that a preload answered by a pending or settled stream still resumes acquisition across eager/on-demand idle and render-deferred sources. The React streaming witness receives the on-demand render-deferral premise, starts local acquisition before the stream settles, and retains server rows until browser acquisition completes. Mutants that never defer, keep the subscriber-or-preload fact across cleanup, ignore preload, never resume a builder's subscriptions, let a deferring subscriber start a base source, build a pooled partition without deferral, never resume it, mark a preload one microtask late, or drop the stream's preload mark each fail it. The React witness checks that `useLiveSuspenseQuery` over a source that already holds the rows renders without the fallback, for eager and on-demand sources and both shapes; creating the Suspense collection started, or not preloading it in render, fails it. A later review added `peer` and `truncate` commands: a peer that starts an idle on-demand source, or a truncate on a running one, made the live query acquire before any subscriber, and both guards are killed separately. Fixed blocks check that a subscriber surviving cleanup is served after a restart while a surviving deferring subscriber is not, and that `toArrayWhenReady()` and `stateWhenReady()` count as a preload. A fixed block checks that a deferring live query is not counted in its source's `subscriberCount` or `subscribers:change` until its first subscriber, while still keeping the source from garbage collection; a Query Collection test checks that it causes no refetch. Another fixed block checks that a preload or subscriber requested by an inner live query's status handler, while the outer query creates its subscription to it, still resumes acquisition. Solid's Suspense receiving witnesses check that a resource whose preload settles first retains its live-query Collection while its sibling remains pending past the unsubscribed floor, then releases that temporary owner on both commit and abandonment, and that a preload settling after its owner unmounts cannot publish an error through the abandoned result. Open: source failure and asynchronous loads before resumption, which the subscription lifecycle oracle owns once acquisition resumes; a resume that throws, which leaves later sources unstarted only for a live query that is already in error; asynchronous sources; and arbitrary Suspense schedules with a `DbClient` beyond the bounded abandoned-render and streaming handoffs recorded above. | | Live-query notification algebra | [real-query and checker calibration](https://github.com/TanStack/db/blob/main/packages/db/tests/notification-laws-oracle.test.ts), [independent Map checker](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts) | One-key synchronous source histories check exact live-query callback reconstruction and unsubscribe silence. Synthetic multi-key histories calibrate the checker; direct source Collection events remain with the change-event history owner. | -| Flat-row change tracking | [flat change tracking oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/flat-change-tracking-oracle.property.test.ts) | Generated callbacks over flat rows, frozen or not and with or without a non-enumerable field (assignments of `0`, `-0`, `NaN`, `undefined`, functions, and fresh objects; reverts; deletions; added fields; data and accessor `defineProperty`; stored drafts; throws), run through the flat tracker, the draft proxy, and an independent model, which must report the same change sets, with `0` and `-0` distinct (maintainer decision, 2026-10-29: writing one over the other is a change), or throw the same error and leave the rows unchanged. Defining a field acts as assigning it, and a non-enumerable field is row data only once written; the proxy broke three of those laws on `main` and the flat tracker one. Diffs using `!==`, `Object.is` alone, or no deletions fail it. It found that the proxy dropped a field added as `undefined` when another field reverted; the pinned history and both campaigns fail without that fix. Non-flat rows must fall back. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | +| Flat-row change tracking | [flat change tracking oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/flat-change-tracking-oracle.property.test.ts) | Generated callbacks over flat rows, frozen or not and with or without a non-enumerable field (assignments of `0`, `-0`, `NaN`, `undefined`, functions, and fresh objects; reverts; deletions; added fields; data and accessor `defineProperty`; stored drafts; throws), run through the flat tracker, the draft proxy, and an independent model, which must report the same change sets, with `0` and `-0` distinct (maintainer decision, 2026-10-29: writing one over the other is a change), or throw the same error and leave the rows unchanged. Defining a field acts as assigning it, and a non-enumerable field is row data only once written; the proxy broke three of those laws on `main` and the flat tracker one. Diffs using `!==`, `Object.is` alone, or no deletions fail it. It found that the proxy dropped a field added as `undefined` when another field reverted; the fixed histories and both campaigns fail without that fix. Non-flat rows must fall back. The [2026-10-01 review](oracle-reviews/2026-10-01-pooled-live-queries.md) records each ORC outcome. | | Virtual props cache | [cache laws](https://github.com/TanStack/db/blob/main/packages/db/tests/virtual-props-cache.test.ts) | Focused laws for the per-key cache of rows read with virtual props: a change's published value is the row later `get` and `toArray` reads return, for every subscriber, after sync and local writes, and a key that is deleted or rolled back leaves the cache. Enriching the value before the previous value fails the first two; skipping the drop on delete events fails the third. Rows read with virtual props through other paths, such as joins, are outside these laws. | -| LocalStorage Collection | [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | Finite two-write histories compare authored same/disjoint-key updates, both handler completion orders, and all four application-decision pairs against public rows, durable rows, caller settlement, and fresh restore. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | -| LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `when('settled')` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Pinned witnesses cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A pinned witness per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, pinned in `local-only.test.ts`. | -| Local-only direct writes | [direct write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) | Focused witnesses pin when a local-only direct write skips the optimistic stage: a completed transaction and one publication per write, and the fallbacks for a pending, persisting, or ambient transaction and a user handler, for insert, update, and delete. Each history also runs on a local-only Collection whose handlers resolve, which must match its rows, change batches, errors, and transaction states, across mixed multi-key batches, a batch that fails midway, schema rejection, and handler rollback. Mutants that drop each guard, write only the first mutation, or never complete the transaction fail them. The change-event history oracle drives the direct path through generated histories (about two thousand writes per run); removing the guard, or guarding only persisting transactions, fails these witnesses. | +| LocalStorage Collection | [write order over every short history](../../packages/db/tests/local-storage-write-order-oracle.test.ts) (`local-storage.write-order`), [peers over every short history](../../packages/db/tests/local-storage-peers-oracle.test.ts) (`local-storage.peers`), [mutation order](../../packages/db/tests/local-storage-order-oracle.test.ts), [peer snapshots and ownership](../../packages/db/tests/local-storage-peer-history.test.ts) (fixed tests; no oracle owner (tracked in #2264)) | The `local-storage.write-order` property runs every history of one to three automatic updates and deletes of two stored rows (1,448 histories), every handler completion order, and every decision vector. After each handler finishes, no accepted write may have settled ahead of an undecided predecessor, and storage may hold only the author-order fold of part of the released prefix; a rejected write may settle as soon as its own handler fails (the two-write loops it replaced also held a rejected later receipt for two microtasks, which no law requires). At settlement, outcomes, durable rows, public rows, and a fresh restore equal the fold of the accepted writes. Against `src/local-storage.ts` mutants it detects every mutant that the finite two-write loops it replaced detected (63), plus one. Inserts are excluded: an insert after a rejected delete of the same key is undecided behaviour. The `local-storage.peers` property runs every history of up to three actions (19,289 histories) across Collections A and B in one tab and C in another tab over the same bytes. Actions are direct, manual-acceptance, handler, and custom-parser writes of string keys and the number key 7, a held update by A, `clearStorage()` from any Collection, `reload()` of C, and delivery of pending storage events. After each action, stored rows equal the model's read-modify-write fold. Every Collection in the writing tab shows the stored rows, because a write reads storage for its whole tab (maintainer decision D43, 2026-11-02, raised by three comparison gaps in the gap report). The holder's own pending row overlays only the holder, and while it is held the holder may show its earlier view of other keys (glossary, accepted sync transaction). The other tab shows a changed row once its event arrives or it reloads. A fresh Collection restores the stored rows. Against `src/local-storage.ts` mutants the LocalStorage oracles detect 201 of 320 with this property and 153 without it; detections in the peer and event functions rise from 36 to 70 of 131. Excluded: write races between tabs on one key, value-normalizing parsers, startup errors, storage faults, and cleanup. A nested fire-and-forget admission checks that the outer write reaches storage first; awaiting the nested receipt inside its earlier handler is an excluded dependency cycle. Handler-free direct insert, update, and delete are checked at the method-return boundary for synchronous Storage effects. A later handler failure is checked while its earlier handler remains held: its receipt rejects and its optimistic row rolls back promptly, while the durable predecessor remains pending. Only two fixed tests in `packages/db/tests/local-storage-order.test.ts` check this (same and disjoint key); no generated history does. Open: `local-storage.write-order` needs a checkpoint a macrotask after a rejected release that compares the rejected write's receipt and public row with the model. Fixed tests in `packages/db/tests/local-storage-order.test.ts` (no oracle owner (tracked in #2264)) cover the next two histories. An automatic insert followed by manual acceptance crosses same-key update versus disjoint insert and both earlier-handler decisions; manual settlement waits behind that handler, and public, durable, and fresh-restore rows match the authored fold. Un-awaited manual acceptance crosses direct and held-predecessor paths with success or Storage failure: both commit and persistence receipts wait for every registered write, report the exact fault on failure, and agree with durable and public rows at settlement. A two-Collection witness checks that one manual receipt waits for both un-awaited acceptances. Peer witnesses check serialized disjoint typed-key writes before delayed event delivery, equal-ID manual Collection ownership, construction-time rejection of direct or shallow-copy reuse before or after first preload with the first Collection still writable, a peer insert during pending local insert, listener release across cleanup/restart, malformed startup snapshots, transient startup read failures and restart, the null-versus-empty storage sentinel, local and peer clear publication with subsequent public/durable agreement and a pending optimistic mutation across clear, and storage/parser read failures during writes and event delivery. Reload witnesses let another tab (a second Storage object over the same bytes) update or delete a kept row and insert a row, or clear storage, while no event reaches the first tab, with and without a prior local edit of the kept row: `reload()` must resolve with the Collection `ready` and its public rows equal to the durable rows and a fresh restore, and the reloaded run must confirm a later write. The adapter that re-inserted stored rows in each run crashed on the update histories and kept the deleted row; a snapshot only for non-empty storage fails the clear histories. The [same-tab audit](oracle-reviews/2026-10-08-localstorage-same-tab.md) records witnesses that withhold events through each persistence receipt: disjoint typed keys, a manually accepted same-key update followed by automatic update and delete, clear, a rejected Storage write, a pending same-key optimistic insert, cleanup/restart, and a write started by a ready-status listener compare public snapshots with the authored durable fold. A default jsdom Storage witness receives a same-tab write. The old event-only adapter fails seven no-event checkpoints. The original adapter also fails the second-handler-first, delayed-peer, equal-ID, listener-release, read-failure, synchronous direct-write, prompt-failure, mixed manual/automatic order, un-awaited manual receipt, reused-options admission, malformed-startup, and clear-publication witnesses. A failed write read rejects without replacing durable rows; a failed event read retains the public snapshot until a later valid event. These checks do not establish simultaneous cross-tab read-modify-write atomicity, atomic acceptance across distinct storage keys, manual acceptance without a mutation Collection owner, arbitrary long histories, native browser scheduling, or synchronization across distinct custom Storage wrapper objects. Existing LocalStorage failure and CRUD tests remain supporting examples. | +| LocalStorage value fidelity | [JSON value model](../../packages/db/tests/local-storage-value-fidelity-oracle.property.test.ts) | With the default JSON parser, an insert, update, manual insert, or manual update is accepted only if a fresh restore can return the row. A recursive value grammar labels each generated value, while it is built, with its expected JSON form or as lost. Lost: functions, symbols, enumerable symbol keys, `NaN` and infinities (also boxed), `undefined` and missing array entries, arrays with extra or symbol properties, a `toJSON` that returns `undefined` in an array, and Map, Set, WeakMap, Promise, RegExp, Uint8Array, and SharedArrayBuffer values. Normalized: `toJSON` values (Date, a plain object, and a function with its own `toJSON`), `undefined` properties and `toJSON` results, `-0`, boxed finite numbers, and non-enumerable symbol keys. Histories cross the four entry paths with and without an automatic handler. At `when('settled')` settlement, an accepted row must keep the authored writer value, store the model's form durably, and restore it in a fresh Collection; a lost row must reject with `SerializationError`, leave the writer's predecessor and storage bytes unchanged, and never call the handler. Fixed histories cover every lost kind and normalization on each path, with and without a handler; a fixed-seed and a random campaign of 300 histories run under `local-storage.value-fidelity`. A fixed histories per entry path uses a `toJSON` that changes after its first call: storage must hold the JSON the check accepted, and serializing the row again during the key check or the snapshot write each fails it. The stringify-only check on `main` fails 162 of 230 cells. The first walker in this PR failed 66 cells of the expanded grammar. Mutants that reject `undefined` properties, accept `undefined` array entries, skip boxed-number unwrapping, treat non-enumerable symbol keys as lost, skip the array-property check, or shrink the opaque-type list each fail at least six cells. Open: custom parsers, including a wrapper around `JSON` (accepted by design, documented); class instances (accepted by design; the guide states they restore as plain objects); cross-realm natives; getters; non-enumerable string data; update payloads whose shape the draft copy changes (array extra properties and symbol keys are dropped, and a hidden symbol key becomes enumerable, so an update of an object with a hidden symbol key rejects although its insert succeeds), which the proxy detachment owner must decide; peer or event delivery of a rejected write, which never reaches storage; and peer delivery of normalized values: the peer owner checks only `Date` at that boundary, so a peer receiving witness for the generated value grammar remains with the peer owner. IndexedDB clone rejection is owned by the IndexedDB settlement and values oracles, which require `SerializationError`; local-only rows keep values by reference, fixed in `local-only.test.ts`. | +| Local-only direct writes | [direct write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) | Focused witnesses fix when a local-only direct write skips the optimistic stage: a completed transaction and one publication per write, and the fallbacks for a pending, persisting, or ambient transaction and a user handler, for insert, update, and delete. Each history also runs on a local-only Collection whose handlers resolve, which must match its rows, change batches, errors, and transaction states, across mixed multi-key batches, a batch that fails midway, schema rejection, and handler rollback. Mutants that drop each guard, write only the first mutation, or never complete the transaction fail them. The change-event history oracle drives the direct path through generated histories (about two thousand writes per run); removing the guard, or guarding only persisting transactions, fails these witnesses. | | Reused sync row objects | [reused row witness](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-reused-row.test.ts) | Focused witnesses for a sync source that changes a stored row object in place: in development, writing it again as an update without `previousValue` throws, while the same write with `previousValue`, or with a new object, moves the row out of an `eq` live query. Production skips the check. The check compares a shallow snapshot taken when the object was written, so it misses changes to nested fields; a write that names `previousValue` and a row whose fields throw on read are not checked. | | Index suggestions | [index suggestion oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-suggestion-oracle.test.ts) | A public filtered live-query Collection checks manual/default, eager, threshold, matching-index, and unrelated-index cases against the documented collection-size suggestion policy. A bounded ordered-query decision table separately classifies no field index, a null-placement mismatch, matching but order-ineligible indexes, and usable candidates in either registration order. It compares the development warning after preload; the matching-index selection owner below checks the chosen reader and ordered request. Two cyclic locale-options cells require the diagnostic to preserve valid query construction with or without a field index, and a custom-collation cell requires remediation to preserve comparator reference identity. The original manual-silence/eager-noise implementation failed two expected observations. The issue #2190 baseline failed four diagnostic cells, including a first-unusable/second-usable candidate order; the first formatter draft failed all three non-JSON-safe remediation cells. The owner does not judge repeated-query warning volume, slow-query timing, production-build suppression, every comparison-option mismatch, or every query shape. It runs in `@tanstack/db`'s `test:oracles` campaign. | -| Minified DB public API | [consumer bundle check](https://github.com/TanStack/db/blob/main/scripts/test-minified-db.mjs) | CI builds `@tanstack/db`, then bundles its built `dist` entry with db-ivm inlined through esbuild `minify: true`. The build renames TypeScript-private members from `packages/db/mangle-cache.json`, so this lane runs against the renamed output that consumers install. It rejects a `dist` that is older than `src`, the cache, or the package's build configuration. `pnpm --filter @tanstack/db test:dist` also runs five db test files against the built `dist`, chosen for coverage of the modules with the most renamed members. `pnpm check:mangle` fails when a cached name is used other than as a private member in db src, is read by another package, appears as a string, or has a short name that collides with a source identifier ([private-member mangling review](oracle-reviews/code-weight-private-member-mangling.md)). It checks every exported error class's current public `name`, the built-in `BasicIndex` resolver name in index metadata and its event, complete query rows (after removing the four documented virtual fields) against an independent array filter/sort/projection, and one live update. The `new.target.name`, public-member mangle, and extra-output-field calibration modes fail at the intended observations. This fixed slice does not replace the unminified generated oracles, exercise framework adapters, or establish stable names for custom index resolvers. | +| Minified DB public API | [consumer bundle check](https://github.com/TanStack/db/blob/main/scripts/test-minified-db.mjs) | CI builds `@tanstack/db`, then bundles its built `dist` entry with db-ivm inlined through esbuild `minify: true`. The build renames TypeScript-private members from `packages/db/mangle-cache.json`, so this grammar runs against the renamed output that consumers install. It rejects a `dist` that is older than `src`, the cache, or the package's build configuration. `pnpm --filter @tanstack/db test:dist` also runs five db test files against the built `dist`, chosen for coverage of the modules with the most renamed members. `pnpm check:mangle` fails when a cached name is used other than as a private member in db src, is read by another package, appears as a string, or has a short name that collides with a source identifier ([private-member mangling review](oracle-reviews/code-weight-private-member-mangling.md)). It checks every exported error class's current public `name`, the built-in `BasicIndex` resolver name in index metadata and its event, complete query rows (after removing the four documented virtual fields) against an independent array filter/sort/projection, and one live update. The `new.target.name`, public-member mangle, and extra-output-field calibration modes fail at the intended observations. This fixed slice does not replace the unminified generated oracles, exercise framework adapters, or establish stable names for custom index resolvers. | | Boundary refinements | [cleanup/restart](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-cleanup-restart-oracle.test.ts), [idle cleanup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-idle-cleanup.test.ts), [issue #1891 async-cleanup review](oracle-reviews/issue-1891-async-cleanup.md), [issue #1891 cleanup-start follow-up](oracle-reviews/issue-1891-cleanup-start-followup.md), [metadata publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-metadata-publication-oracle.property.test.ts), [row metadata composition](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts), [nested-begin metadata rebuild review](oracle-reviews/2026-10-06-nested-begin-metadata-rebuild.md), [state mutation round 4](oracle-reviews/2026-10-06-state-mutation-round-4.md), [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts), [acquisition cells](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-cells.test.ts) (fixed tests; no oracle owner (tracked in #2264)), [D2 source reconciliation](https://github.com/TanStack/db/blob/main/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts), [top-K support windows](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts), [nested Query work](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts) | Explicit lifecycle products, independent source maps and weighted relations, exact publication cuts, support/multiplicity, and value-plus-work observations. These refine the larger subsystem models; they do not replace them. The cleanup/restart owner's failing-dependents product crosses every ordered pair of resolving, throwing, rejecting, held, and pooled-throwing dependent ends with synchronous, held, and rejecting adapter cleanup and both release orders, with declared reach; it rejects a cleanup that stops at a throwing dependent, settles with `Promise.all`, or waits only for its dependents. Effects and nested live-query chains with failing ends are not generated. | -| Small structures and test mechanics | [SortedMap](https://github.com/TanStack/db/blob/main/packages/db/tests/SortedMap-oracle.test.ts), [cleanup queue](https://github.com/TanStack/db/blob/main/packages/db/tests/cleanup-queue-oracle.property.test.ts), [live-query GC clock](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-clock.test.ts), [guarded replay](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-replay.test.ts) | Map/full-sort and elapsed appointment-list models with executed target/seed/path checks. SortedMap promises what Collection state uses, not full Map parity (maintainer decision). Keys are strings or numbers: `sync.write` and mutations throw `InvalidKeyError` or `UndefinedKeyError` for any other key, and grouped live queries key nullish, boolean, and bigint group values with strings (maintainer decision, 2026-10-27). The model is a Map of numbers plus a full sort; production stores row objects so the driver can change one in place. Keys include `NaN`, which a grouped live query uses for a `NaN` group; it is one key that key order places after every other number. Generated histories mix deferred and immediate sets and deletes, clear, deferred bursts of 17 to 41 keys, prunes that delete entries with deferOrder while iterating, and in-place changes of a deferred or a placed row. A prune must yield the order it began with. A row changed before restore sorts by its current value; once a placed row changes in place, comparator order downgrades to membership (each key once, current value) for the rest of the history, and a later write or delete affects only its own key. At each ordered read with a comparator, restoring k deferred keys among n others makes at most k * (ceil(log2 k) + ceil(log2 (n + 1)) + 2) comparator calls, and none with nothing deferred. Declared reach (minimum 5, per campaign of 100): burst restorations, an entry yielded after a prune deleted one, a placed `NaN` key written without deferral (7 to 16), a restore whose bound is below n (19 to 30), a changed placed row later written (11 to 20), and a deferred row changed before restore (11 to 19). Mutants that re-sort every key (108 calls against a bound of 7), re-sort the kept keys with the dirty keys appended (34 against 7), re-sort clean scans, drop the scan fallback for a changed row, match slots by `===` (a placed `NaN` key then removes the last key), keep dirty slots, apply deferred deletes immediately, or place a deferred row by its value at the write each fail. Supplemental examples in `SortedMap.test.ts` pin splice movement at scale, a key iterator opened before a deferred restore, scan reuse, and public single-transaction and queued-transaction Collection sync paths. Not promised: other mutation during iteration (immediate writes, clear), the order of rows once a placed row changed in place, keys outside the key type, `-0` keys, a comparator that throws, and the work of key order without a comparator, which the public API cannot observe. `forEach` was removed; SortedMap is not exported. Wall-clock steps do not move due times, and a focused public witness checks source subscription release. Callback-reentrant scheduling, OS suspend/resume, environments without `performance.now()`, and fake/real Performance clock replacement while GC is pending remain outside this coverage. Tests must drain or reset pending GC before switching timer providers. | +| Small structures and test mechanics | [SortedMap](https://github.com/TanStack/db/blob/main/packages/db/tests/SortedMap-oracle.test.ts), [cleanup queue](https://github.com/TanStack/db/blob/main/packages/db/tests/cleanup-queue-oracle.property.test.ts), [live-query GC clock](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-clock.test.ts), [guarded replay](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-replay.test.ts) | Map/full-sort and elapsed appointment-list models with executed target/seed/path checks. SortedMap promises what Collection state uses, not full Map parity (maintainer decision). Keys are strings or numbers: `sync.write` and mutations throw `InvalidKeyError` or `UndefinedKeyError` for any other key, and grouped live queries key nullish, boolean, and bigint group values with strings (maintainer decision, 2026-10-27). The model is a Map of numbers plus a full sort; production stores row objects so the driver can change one in place. Keys include `NaN`, which a grouped live query uses for a `NaN` group; it is one key that key order places after every other number. Generated histories mix deferred and immediate sets and deletes, clear, deferred bursts of 17 to 41 keys, prunes that delete entries with deferOrder while iterating, and in-place changes of a deferred or a placed row. A prune must yield the order it began with. A row changed before restore sorts by its current value; once a placed row changes in place, comparator order downgrades to membership (each key once, current value) for the rest of the history, and a later write or delete affects only its own key. At each ordered read with a comparator, restoring k deferred keys among n others makes at most k * (ceil(log2 k) + ceil(log2 (n + 1)) + 2) comparator calls, and none with nothing deferred. Declared reach (minimum 5, per campaign of 100): burst restorations, an entry yielded after a prune deleted one, a placed `NaN` key written without deferral (7 to 16), a restore whose bound is below n (19 to 30), a changed placed row later written (11 to 20), and a deferred row changed before restore (11 to 19). Mutants that re-sort every key (108 calls against a bound of 7), re-sort the kept keys with the dirty keys appended (34 against 7), re-sort clean scans, drop the scan fallback for a changed row, match slots by `===` (a placed `NaN` key then removes the last key), keep dirty slots, apply deferred deletes immediately, or place a deferred row by its value at the write each fail. Supplemental examples in `SortedMap.test.ts` fix splice movement at scale, a key iterator opened before a deferred restore, scan reuse, and public single-transaction and queued-transaction Collection sync paths. Not promised: other mutation during iteration (immediate writes, clear), the order of rows once a placed row changed in place, keys outside the key type, `-0` keys, a comparator that throws, and the work of key order without a comparator, which the public API cannot observe. `forEach` was removed; SortedMap is not exported. Wall-clock steps do not move due times, and a focused public witness checks source subscription release. Callback-reentrant scheduling, OS suspend/resume, environments without `performance.now()`, and fake/real Performance clock replacement while GC is pending remain outside this coverage. Tests must drain or reset pending GC before switching timer providers. | The index-suggestion oracle's PR #2196 extension checks a matching local custom-collation index against the full-source request and retained warning, @@ -2119,50 +2151,52 @@ acquisitions and warning text, not later window moves or real-provider cost. ## Unit-test retirement transfers in PR #2116 -The [direct index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts) -owns the seven behaviors moved from index unit tests: an absent lower bound -with an exclusive flag; exact membership through comparator-equal updates; -`null` and `undefined` exact and `IN` membership, including repeated owners; -undefined endpoint and cursor traversal; and explicit, omitted, and -double-undefined range bounds. A source Map predicts exact key sets and -ordered reads after bounded direct actions on BasicIndex and BTreeIndex. A -full-build witness catches native `Array.sort` moving raw `undefined` last. -The [round 1](oracle-reviews/index-unit-retirement-round-1.md), +The +[direct index refinement oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update-oracle.property.test.ts) +owns the seven behaviors moved from index unit tests: an absent lower bound with +an exclusive flag; exact membership through comparator-equal updates; `null` and +`undefined` exact and `IN` membership, including repeated owners; undefined +endpoint and cursor traversal; and explicit, omitted, and double-undefined range +bounds. A source Map predicts exact key sets and ordered reads after bounded +direct actions on BasicIndex and BTreeIndex. A full-build witness catches native +`Array.sort` moving raw `undefined` last. The +[round 1](oracle-reviews/index-unit-retirement-round-1.md), [round 2](oracle-reviews/index-unit-retirement-round-2.md), [round 3](oracle-reviews/index-unit-retirement-round-3.md), and [round 4](oracle-reviews/index-unit-retirement-round-4.md) ledgers retain each -retired assertion. The [build review](oracle-reviews/basic-index-nullish-build.md) -records the direct RED/GREEN repair. A separate mixed number/string lane -checks that full direct traversals and one indexed, bounded public Collection -snapshot return each live source key once after deletion and after a direct -delete/update/re-add history. Two more direct histories keep an ordinary string -beside ordered ZonedDateTime or Duration keys and verify key conservation after -one Temporal deletion. An en-US locale and a custom string-collation cell compare -the first bounded public row through a scan and each index when an ordinary -string begins the internal encoding prefix. An `origin/main` probe failed the -default-locale public prefix case. The pre-classifier PR comparator failed all -four public prefix cases and both BTree Temporal-deletion histories. This owner -also compares complete forward and reverse reads through `IndexInterface`, -without prescribing index buckets or a BTree representative. A fixed churn -witness holds one live row while 16, 64, or 256 earlier distinct values are -added and removed; its public `takeFromStart(1)` result and comparator-call -growth reject retained empty buckets that add comparator work. This is a bounded -read-work check, not a memory bound or a general complexity proof. A deliberate -empty-bucket mutant failed at the intended comparator-count comparison. -The invalid-comparator owner checks rejection exactly when a comparison -returns a non-number or `NaN` and checks rejected removal atomicity; it does -not require a comparison merely because one comparator-equal row is removed. -The binary lane checks fresh equal-content removal, re-addition, and -replacement beside another byte value. It compares complete forward and -reverse key reads to a source Map. Separate short and 8193-byte pairs check -exact membership and complete forward and reverse key conservation when only -the last byte differs. An intermediate PR BTree rewrite lost a surviving key -after one equal-content owner left; the expanded lane rejected that result. -A separate two-value Date, Temporal.Instant, and Uint8Array callback lane -checks legacy scalar input kinds on direct add only. Arbitrary callbacks and -update, build, and range paths are outside that lane. -This owner does not prescribe mixed-kind order or cover binary range ordering, -all JavaScript value kinds, or general Collection publication histories. +retired assertion. The +[build review](oracle-reviews/basic-index-nullish-build.md) records the direct +RED/GREEN repair. A separate mixed number/string grammar checks that full direct +traversals and one indexed, bounded public Collection snapshot return each live +source key once after deletion and after a direct delete/update/re-add history. +Two more direct histories keep an ordinary string beside ordered ZonedDateTime +or Duration keys and verify key conservation after one Temporal deletion. An +en-US locale and a custom string-collation cell compare the first bounded public +row through a scan and each index when an ordinary string begins the internal +encoding prefix. An `origin/main` probe failed the default-locale public prefix +case. The pre-classifier PR comparator failed all four public prefix cases and +both BTree Temporal-deletion histories. This owner also compares complete +forward and reverse reads through `IndexInterface`, without prescribing index +buckets or a BTree representative. A fixed churn witness holds one live row +while 16, 64, or 256 earlier distinct values are added and removed; its public +`takeFromStart(1)` result and comparator-call growth reject retained empty +buckets that add comparator work. This is a bounded read-work check, not a +memory bound or a general complexity proof. A deliberate empty-bucket mutant +failed at the intended comparator-count comparison. The invalid-comparator owner +checks rejection exactly when a comparison returns a non-number or `NaN` and +checks rejected removal atomicity; it does not require a comparison merely +because one comparator-equal row is removed. The binary grammar checks fresh +equal-content removal, re-addition, and replacement beside another byte value. +It compares complete forward and reverse key reads to a source Map. Separate +short and 8193-byte pairs check exact membership and complete forward and +reverse key conservation when only the last byte differs. An intermediate PR +BTree rewrite lost a surviving key after one equal-content owner left; the +expanded grammar rejected that result. A separate two-value Date, +Temporal.Instant, and Uint8Array callback grammar checks legacy scalar input +kinds on direct add only. Arbitrary callbacks and update, build, and range paths +are outside that grammar. This owner does not prescribe mixed-kind order or +cover binary range ordering, all JavaScript value kinds, or general Collection +publication histories. The [comparison oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison-oracle.property.test.ts) owns the transferred same-realm NaN and invalid-Date default-order cases, @@ -2218,17 +2252,19 @@ The deep-equality and Collection change-event history owners check a Duration field replacement and its subscriber mirror. These fixed histories do not establish arbitrary multirow Duration updates or live-query publication. -The [Temporal index order tests](https://github.com/TanStack/db/blob/main/packages/db/tests/temporal-index-order.test.ts) -(fixed tests; no oracle owner (tracked in #2264)) covers ZonedDateTime instant ordering and time-only Duration ordering for direct -BasicIndex/BTreeIndex reads and bounded public first-row reads. It separates -exact Duration fields from order ties, including exclusive cursor behavior. -An extended-year PlainDate lane checks calendar order under lexical string -collation through ready scan and direct indexed snapshots, with eager live-query -preload as parity evidence. The old indexed direct path chose the wrong year. -An exception lane requires ready scan, BasicIndex, and BTreeIndex direct reads -and eager live-query preloads to reject mixed PlainDate/PlainDateTime values and -distinct PlainMonthDays, as changelog #1519 specifies. The old indexed direct -path returned a row instead. Calendar-unit Duration order, other extended-year +The +[Temporal index order tests](https://github.com/TanStack/db/blob/main/packages/db/tests/temporal-index-order.test.ts) +(fixed tests; no oracle owner (tracked in #2264)) covers ZonedDateTime instant +ordering and time-only Duration ordering for direct BasicIndex/BTreeIndex reads +and bounded public first-row reads. It separates exact Duration fields from +order ties, including exclusive cursor behavior. An extended-year PlainDate +grammar checks calendar order under lexical string collation through ready scan +and direct indexed snapshots, with eager live-query preload as parity evidence. +The old indexed direct path chose the wrong year. An exception grammar requires +ready scan, BasicIndex, and BTreeIndex direct reads and eager live-query +preloads to reject mixed PlainDate/PlainDateTime values and distinct +PlainMonthDays, as changelog #1519 specifies. The old indexed direct path +returned a row instead. Calendar-unit Duration order, other extended-year histories, on-demand exception publication, arbitrary updates, and custom index callbacks remain outside this owner. @@ -2334,11 +2370,12 @@ domain: a structurally sound index can temporarily lose ordered-read eligibility as row kinds change, without warranting a duplicate auto-index. The [PR #2116 confirmed-fixes review](oracle-reviews/pr-2116-confirmed-fixes.md) -records baseline failures, hostile controls, and the bounded scope of these -repairs. The [deferred findings record](oracle-reviews/pr-2116-deferred-findings.md) -tracks remaining design and coverage questions, including valid Temporal kinds -without public order and mixed-kind query order. Earlier PR-branch -counterexamples do not by themselves establish an `origin/main` failure. +records baseline failures, mutant controls, and the bounded scope of these +repairs. The +[deferred findings record](oracle-reviews/pr-2116-deferred-findings.md) tracks +remaining design and coverage questions, including valid Temporal kinds without +public order and mixed-kind query order. Earlier PR-branch counterexamples do +not by themselves establish an `origin/main` failure. The Query DB ownership oracle also owns the explicit mutation-handler refresh contract ([evidence](oracle-reviews/pr-2171-explicit-query-refresh.md)). Its 18-history table crosses insert, update, and delete with no @@ -2472,24 +2509,24 @@ behavior. A caller using direct writes must accept the scoped-cache limits or request an explicit refetch. -The ownership oracle also checks five later boundaries from PR #2065 review: -a derived `select` still applies a held fetch's rows and settles eager -readiness after a direct insert; overlapping explicit refetch callers follow -the replacement request after a silent cancellation; a fetched result queued -behind a persisted-baseline scan cannot overwrite a later accepted write; a -zero-lease subset observer retained by a listener receives a direct cache -patch before reacquisition; and eight retired on-demand subsets leave no -fetch-start positions (removing the retirement call fails at eight retained -entries). The Query error, invalidation, and staleness checks now cover both -eager and on-demand modes. These are controlled Query Core and persistence -seams. They do not establish every derived response shape, arbitrary -overlapping refetch schedule, native SQLite timing for the queued-result cut, -or unbounded cache-memory growth. This owner retains those boundaries; the -persisted direct-write oracle owns native durable-order receiving cases. -The retained-Query witness holds an empty-cache fetch across subset unload and +The ownership oracle also checks five later boundaries from PR #2065 review: a +derived `select` still applies a held fetch's rows and settles eager readiness +after a direct insert; overlapping explicit refetch callers follow the +replacement request after a silent cancellation; a fetched result queued behind +a persisted-baseline scan cannot overwrite a later accepted write; a zero-lease +subset observer retained by a listener receives a direct cache patch before +reacquisition; and eight retired on-demand subsets leave no fetch-start +positions (removing the retirement call fails at eight retained entries). The +Query error, invalidation, and staleness checks now cover both eager and +on-demand modes. These are controlled Query Core and persistence seams. They do +not establish every derived response shape, arbitrary overlapping refetch +schedule, native SQLite timing for the queued-result cut, or unbounded +cache-memory growth. This owner retains those boundaries; the persisted +direct-write oracle owns native durable-order receiving cases. The +retained-Query witness holds an empty-cache fetch across subset unload and reacquisition. A direct delete then requires an explicit refetch to start a -post-write request and preserve the deleted public row at settlement; without -a later write, the same refetch may reuse the in-flight request. A separate +post-write request and preserve the deleted public row at settlement; without a +later write, the same refetch may reuse the in-flight request. A separate checkpoint keeps the detached fetch-start position until that request settles and retires it afterward. Two further shared-Query histories add A's sync restart after a direct insert/delete while B's pre-write request remains in @@ -2497,15 +2534,15 @@ flight. Without an explicit refetch, the old result must preserve the delete; with one, A must start a post-write request. Both were RED before physical fetch-start positions survived the restart and are GREEN afterward. These controlled histories do not establish every persisted adapter or Query -cache-removal schedule. -The [intermediate review at 74e399150](oracle-reviews/pr-2065-74e399-review.md) -adds ordinary-unload and unobserved-fetch histories to that request-order law. -The [final direct-write authority review](oracle-reviews/2026-10-12-query-direct-write-authority.md) -records the later law decisions, RED/GREEN evidence, and ten-item audit. -The [four-review follow-up](oracle-reviews/2026-10-12-query-authority-oracle-followup.md) +cache-removal schedule. The +[intermediate review at 74e399150](oracle-reviews/pr-2065-74e399-review.md) adds +ordinary-unload and unobserved-fetch histories to that request-order law. The +[final direct-write authority review](oracle-reviews/2026-10-12-query-direct-write-authority.md) +records the later law decisions, RED/GREEN evidence, and ten-item audit. The +[four-review follow-up](oracle-reviews/2026-10-12-query-authority-oracle-followup.md) audits legal subset predicates, cache-projection boundaries, scoped omissions, -and the generated cursor manual-write campaign against its repaired commit. -The ownership oracle also checks derived-select reacquisition and eager restart, +and the generated cursor manual-write campaign against its repaired commit. The +ownership oracle also checks derived-select reacquisition and eager restart, explicit subset refetch, a derived result held behind `deferDataRefresh`, a provisional cache row awaiting persisted application, aliased physical Queries, and the boundary between an in-flight pending Query and an idle empty cache @@ -2515,20 +2552,19 @@ patch, resets the physical Query so the next fetch repeats the patch's numeric update count, and requires B's load to settle on the new provider row. It runs with a new response array and with the patch's exact array reference. This checks the authority boundary at fetch settlement, but does not model every -Query Core reset schedule or custom-hash collision. -An outside-prefix custom-hash collision remains in Query Core's cache after a -direct write. The disabled reacquisition witness requires the load to wait for -an explicit provider result, then checks that result and ownerless-row -retirement. It covers one physical alias and an unbounded scope, not arbitrary -hash functions or windowed scopes. -The ordinary-unload, provisional-row, and alias tests reject their respective -hostile classifiers at public-row or cache checkpoints. These finite histories -do not establish every persistence schedule, every Query hash collision, or a -full offline replay; those remain owned by the Query DB ownership and offline -receiving oracles. Query Core 5.90.15 is the lowest version on which the full -Query DB suite passed; 5.90.14 failed error-recovery witnesses, and 5.85.2 -failed the post-write refetch witness. Older versions are excluded by the peer -minimum. +Query Core reset schedule or custom-hash collision. An outside-prefix +custom-hash collision remains in Query Core's cache after a direct write. The +disabled reacquisition witness requires the load to wait for an explicit +provider result, then checks that result and ownerless-row retirement. It covers +one physical alias and an unbounded scope, not arbitrary hash functions or +windowed scopes. The ordinary-unload, provisional-row, and alias tests reject +their respective mutant classifiers at public-row or cache checkpoints. These +finite histories do not establish every persistence schedule, every Query hash +collision, or a full offline replay; those remain owned by the Query DB +ownership and offline receiving oracles. Query Core 5.90.15 is the lowest +version on which the full Query DB suite passed; 5.90.14 failed error-recovery +witnesses, and 5.85.2 failed the post-write refetch witness. Older versions are +excluded by the peer minimum. The ownership oracle now checks a direct row's ownership lifetime across one and two subset claims, with and without Collection row metadata. A direct @@ -2840,16 +2876,17 @@ remain current. Custom parsers still read back. These finite witnesses do not establish recovery of an invalid writer key extractor or manual acceptance without a mutation Collection owner. -The [follow-up oracle review](oracle-reviews/2026-10-08-localstorage-final-review.md) +The +[follow-up oracle review](oracle-reviews/2026-10-08-localstorage-final-review.md) adds direct-owner idle, ready, and cleaned-up histories for module-level acceptance into a DbClient materialization. It checks the manual receipt, -durable snapshot, active public snapshots, and fresh restore. A hostile subject +durable snapshot, active public snapshots, and fresh restore. A mutant subject that drops transaction commit work fails the held-receipt checkpoint. The peer owner checks a peer write admitted by a restore subscriber, custom-parser normalization of an authored insert and update without a version-token change, -invalid events followed by same-token repair, and malformed stored row shape -or key identity before startup opens a sync transaction. The order owner checks -a handler-free direct write at method return immediately after a synchronous +invalid events followed by same-token repair, and malformed stored row shape or +key identity before startup opens a sync transaction. The order owner checks a +handler-free direct write at method return immediately after a synchronous handler failure. These finite witnesses do not prove arbitrary reentrant subscriber histories or cross-tab atomicity. Default JSON intentionally keeps the writer's authored rich value until restore; a Date witness records the @@ -3293,20 +3330,21 @@ esbuild, an open from inside a worker, a Vite client injected without a script [PR #2103 review record](oracle-reviews/pr-2103-separate-wasm.md) classifies each calibration control and records every applicable requirement. -The [browser OPFS lifecycle owner](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts) +The +[browser OPFS lifecycle owner](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts) also generates explicit close histories: zero to three pending executes, how many answer while close is in flight and in which order, a successful close and every worker error classification, a concurrent close, an execute submitted -during close, and pagehide or a Worker terminal event before the close -response, which then rejects close and pending siblings with its own failure. At close settlement, answered executes keep their rows and the -rest reject with `AbortError`. Every close call shares the first close's -outcome. The worker receives exactly one close request, and work submitted -after close starts never reaches it. Rejecting at close start, posting a second -close, admitting work during close, deferring rejection past close settlement, -and the pre-fix hang each fail this lane, as does ignoring pagehide or a Worker -error while close is in flight. The controlled worker -answers in any order, so these histories also cover a close response that -overtakes pending work. +during close, and pagehide or a Worker terminal event before the close response, +which then rejects close and pending siblings with its own failure. At close +settlement, answered executes keep their rows and the rest reject with +`AbortError`. Every close call shares the first close's outcome. The worker +receives exactly one close request, and work submitted after close starts never +reaches it. Rejecting at close start, posting a second close, admitting work +during close, deferring rejection past close settlement, and the pre-fix hang +each fail this grammar, as does ignoring pagehide or a Worker error while close +is in flight. The controlled worker answers in any order, so these histories +also cover a close response that overtakes pending work. The [OPFS request-order oracle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts) states that a connection settles requests in acceptance order with sequential @@ -3333,31 +3371,31 @@ than one other handle holder. The review record is The ordered-work owner also checks that an Effect with `skipInitial` waits for joined demand before ending initial callback suppression. A deterministic work counter rejects an extra joined-filter graph schedule on root readiness while -preserving the joined-source wake-up. Nonterminal per-demand failure recovery -is outside this owner: `failDemand` enters terminal live-query error while its -plan record remains unsettled until sync teardown. A future recovery contract -must reconcile plan and subscription pending state before reopening publication. -Its startup witness covers one synchronous rank update after an indexed page -load and before the last source subscription returns. It does not cover every -source ordering, multiple buffered mutations, or asynchronous startup changes. -The [ordered Effect startup review](oracle-reviews/gap-06-ordered-effect-startup.md) -records its baseline and hostile-mutant observations. +preserving the joined-source wake-up. Nonterminal per-demand failure recovery is +outside this owner: `failDemand` enters terminal live-query error while its plan +record remains unsettled until sync teardown. A future recovery contract must +reconcile plan and subscription pending state before reopening publication. Its +startup witness covers one synchronous rank update after an indexed page load +and before the last source subscription returns. It does not cover every source +ordering, multiple buffered mutations, or asynchronous startup changes. The +[ordered Effect startup review](oracle-reviews/gap-06-ordered-effect-startup.md) +records its baseline and mutant-mutant observations. The Collection lifecycle change-event history owner also checks eager auto-index -equality buckets and fresh indexed `eq` live queries against its independent -row Map. Its required same-turn delete/reinsert prefix reaches an already-built -index for both BasicIndex and BTreeIndex. In the batched lane, public rows and -equality buckets are checked after each mutation returns, before any transaction -is awaited; the batched final state and each sequential settled prefix are also -checked. Fresh indexed queries are checked at the final checkpoint. -Fixed and random tails check nearby legal histories. One temporary mutant that -skipped index updates failed the fixed batched final count: one indexed key -versus two model rows. Another fault that preserved the final keys failed the -new returned-mutation check. The direct index refinement owner does not prove -this Collection mutation path. Callback-time index agreement, -the reported insert-seeded string-key setup, adapter cancellation, and -non-local-only persistence remain outside this lane. -The [issue #1912 review record](oracle-reviews/issue-1912-eager-index-history.md) +equality buckets and fresh indexed `eq` live queries against its independent row +Map. Its required same-turn delete/reinsert prefix reaches an already-built +index for both BasicIndex and BTreeIndex. In the batched grammar, public rows +and equality buckets are checked after each mutation returns, before any +transaction is awaited; the batched final state and each sequential settled +prefix are also checked. Fresh indexed queries are checked at the final +checkpoint. Fixed and random tails check nearby legal histories. One temporary +mutant that skipped index updates failed the fixed batched final count: one +indexed key versus two model rows. Another fault that preserved the final keys +failed the new returned-mutation check. The direct index refinement owner does +not prove this Collection mutation path. Callback-time index agreement, the +reported insert-seeded string-key setup, adapter cancellation, and +non-local-only persistence remain outside this grammar. The +[issue #1912 review record](oracle-reviews/issue-1912-eager-index-history.md) records the grammar controls, exact reviewed commit, and ORC audit. The [live Electric immediate-reload witness](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/electric-immediate-reload.opfs.spec.ts) @@ -3372,55 +3410,52 @@ pre-publication tick. This bounded history does not prove Windows/Edge behavior, offline recovery, arbitrary later re-inserts, or a durable-acknowledgment contract for `awaitTxId`. -The [optimistic-history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts) +The +[optimistic-history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts) now owns the held-row law for sync transactions deferred behind a wrapping -sync's durable step, and checks that every optimistic mutation is visible in -the tick that makes it. Its -[persisted lane](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts) -runs the shared grammar through `persistedCollectionOptions` with -`hold-store` and `release-store` steps and a handler that commits its own -server row. It was RED before the wrapper reported deferred sync keys to -core: when an earlier source transaction's store was held, a settled row -dropped to an older synced row before its deferred server row applied -(insert-then-update and insert-then-delete histories). It is green now, and -mutants that drop deferred keys from the hold, drop the held row at a drain, -or skip the held overlay after a drain each fail it. The grammar also -aborts a deferred transaction before its turn, and ends every history with a -source transaction on every key, so a stale hold or leaked local attribution -shows. That step was RED on the first fix: the wrapper marked an aborted -transaction as handed to the Collection, so its hold never ended, and a -non-optimistic write kept local attribution for a dropped transaction. A -campaign check requires each run to reach a hold across a drain and a hold -released by an abort. Open: wrapper cleanup or source restart while a -transaction is deferred has no grammar step, and a held-row release filtered -as a recent sync echo was not reproduced. The lane uses an in-memory -adapter. -Several tabs on one durable store each run their own source and store -every source transaction they receive. DB promises only eventual -consistency between them: while their streams interleave, a lagging tab's -copy of an older transaction can be stored and announced after a newer one, -so a tab can briefly show an older server state. Every tab delivers the -same log in order, so durable state and every tab converge once the streams -catch up. No lane checks this yet. -A counterexample bounds that convergence claim -([#2209](https://github.com/TanStack/db/issues/2209)). Two Browser tabs -share one SQLite file through the real coordinator, with a controlled -Electric stream per tab. Tab A's stream applies v1 then v2 at `30_0`. Tab B's -lagging stream applies v1 at `20_0`, and tab B closes before it reaches v2. -Tab A then shows v1, and the durable `electric:resume` offset is back at -`20_0`. Nothing redelivers v2 to tab A while it stays open, because its -stream is already past it. A tab that opens later resumes from `20_0` and -repairs the cache in that history. Restart formerly failed when a later commit advanced the resume position -over stale rows (#2281). The eager SQLite bridge now records source -continuity atomically and requires a fresh snapshot on reopen after a gap; -see the [repair evidence](oracle-reviews/issue-2281-investigation.md). -This preserves temporary arrival-order regressions and leaves #2209 open. The -multi-tab Electric oracle in `browser-db-sqlite-persistence` pins this -history with `it.fails` beside a passing control in which the lagging tab -catches up. Ordering the copies would need a generic -source position on sync commits; Electric already stores `{ handle, offset }` -with each commit, PowerSync does not use this wrapper, and persisted Query -collections have no log position. +sync's durable step, and checks that every optimistic mutation is visible in the +tick that makes it. Its +[persisted production path](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts) +runs the shared grammar through `persistedCollectionOptions` with `hold-store` +and `release-store` steps and a handler that commits its own server row. It was +RED before the wrapper reported deferred sync keys to core: when an earlier +source transaction's store was held, a settled row dropped to an older synced +row before its deferred server row applied (insert-then-update and +insert-then-delete histories). It is green now, and mutants that drop deferred +keys from the hold, drop the held row at a drain, or skip the held overlay after +a drain each fail it. The grammar also aborts a deferred transaction before its +turn, and ends every history with a source transaction on every key, so a stale +hold or leaked local attribution shows. That step was RED on the first fix: the +wrapper marked an aborted transaction as handed to the Collection, so its hold +never ended, and a non-optimistic write kept local attribution for a dropped +transaction. A campaign check requires each run to reach a hold across a drain +and a hold released by an abort. Open: wrapper cleanup or source restart while a +transaction is deferred has no grammar step, and a held-row release filtered as +a recent sync echo was not reproduced. The production path uses an in-memory +adapter. Several tabs on one durable store each run their own source and store +every source transaction they receive. DB promises only eventual consistency +between them: while their streams interleave, a lagging tab's copy of an older +transaction can be stored and announced after a newer one, so a tab can briefly +show an older server state. Every tab delivers the same log in order, so durable +state and every tab converge once the streams catch up. No production path +checks this yet. A counterexample bounds that convergence claim +([#2209](https://github.com/TanStack/db/issues/2209)). Two Browser tabs share +one SQLite file through the real coordinator, with a controlled Electric stream +per tab. Tab A's stream applies v1 then v2 at `30_0`. Tab B's lagging stream +applies v1 at `20_0`, and tab B closes before it reaches v2. Tab A then shows +v1, and the durable `electric:resume` offset is back at `20_0`. Nothing +redelivers v2 to tab A while it stays open, because its stream is already past +it. A tab that opens later resumes from `20_0` and repairs the cache in that +history. Restart formerly failed when a later commit advanced the resume +position over stale rows (#2281). The eager SQLite bridge now records source +continuity atomically and requires a fresh snapshot on reopen after a gap; see +the [repair evidence](oracle-reviews/issue-2281-investigation.md). This +preserves temporary arrival-order regressions and leaves #2209 open. The +multi-tab Electric oracle in `browser-db-sqlite-persistence` fixes this history +with `it.fails` beside a passing control in which the lagging tab catches up. +Ordering the copies would need a generic source position on sync commits; +Electric already stores `{ handle, offset }` with each commit, PowerSync does +not use this wrapper, and persisted Query collections have no log position. The [PR #1914 follow-up review record](oracle-reviews/pr-1914-immediate-reload-follow-up.md) preserves the held-checkpoint and extra-POST mutant results at the exact @@ -3808,20 +3843,20 @@ to the supplied live scope and its erroneous scheduled call; temporal overlap alone is insufficient. Captured primary evidence survives cleanup failures. The default coordinator now uses the supplied scoped adapter for that call, -without caching it. The initial repair receipt on main at `a37e69ab` records -48 core routing assertion failures and eight real scheduler-cycle assertion +without caching it. The initial repair receipt on main at `a37e69ab` records 48 +core routing assertion failures and eight real scheduler-cycle assertion failures for the original production code. The latest review follow-up passes -640 persisted-oracle checks (with one existing todo) and 25 Chromium OPFS checks. -The original eight hostile repairs cover premature receipts, cached loans, -omitted data, reverse FIFO and suppressed/misclassified failure. New calibration -shows the old browser checks accepting cached loans and ordinary scheduling -bypass; the strengthened receiver rejects them at tail routing and the complete -scope boundary. A no-scope error-identity mutant is rejected by the restored -failure paths. The +640 persisted-oracle checks (with one existing todo) and 25 Chromium OPFS +checks. The original eight mutant repairs cover premature receipts, cached +loans, omitted data, reverse FIFO and suppressed/misclassified failure. New +calibration shows the old browser checks accepting cached loans and ordinary +scheduling bypass; the strengthened receiver rejects them at tail routing and +the complete scope boundary. A no-scope error-identity mutant is rejected by the +restored failure paths. The [loss audit and exact content bindings](oracle-reviews/issue-2046-hydration-loss-audit.md) -preserve all source recoveries, decisions, commands, calibration and limits; -the [initial review](oracle-reviews/issue-2046-hydration-commit-deadlock.md) -retains the original issue ledger. +preserve all source recoveries, decisions, commands, calibration and limits; the +[initial review](oracle-reviews/issue-2046-hydration-commit-deadlock.md) retains +the original issue ledger. - [ ] **Scoped lifecycle compositions.** The persisted-history owner needs distinct-scope witnesses for dependent and post-publication aborts, @@ -3832,7 +3867,7 @@ retains the original issue ledger. - [ ] **Nested work under fairness backlog.** The shared-driver fairness owner (`tests/shared-driver-fairness-oracle.ts` in the browser package, with its OPFS receiver) needs a nested source commit inside a hydrate while both - lanes retain a backlog that distinguishes K=1 from K=2. Finite peer + grammars retain a backlog that distinguishes K=1 from K=2. Finite peer progress here does not establish that composition. - [ ] **Elected-owner/host handoffs.** The per-Collection coordinator owner retains transfer/cross-tab RPC while nested durability is pending. The @@ -3954,50 +3989,50 @@ The post-merge review added three missing domains to existing owners: This is distinct from exactly-once execution across independent owners. - Settlement-time drop of optimistic state is owned by the [optimistic history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts) - and [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts). + and + [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts). A transaction's optimistic state drops when its mutation function settles. - Sync transactions committed while it persisted are held and publish with - that drop; `when('settled')` settles after that publication. Handler sync writes + Sync transactions committed while it persisted are held and publish with that + drop; `when('settled')` settles after that publication. Handler sync writes are generated at every cut, with and without awaiting their acceptance. Receipts stay pending until visible. With one persisting local mutation and no truncate, the first queued same-key source transaction consumes local attribution at settlement if that mutation succeeds. An absent-key delete consumes it without leaving a row. Repeated same-key writes in that first - atomic source transaction keep its attribution. Later source transactions - are `'remote'` without another local owner. A truncate can publish while the + atomic source transaction keep its attribution. Later source transactions are + `'remote'` without another local owner. A truncate can publish while the mutation remains active and label its same-key row `'local'` even if that - mutation later fails. Attribution uses key and timing; `SyncConfig.write` - has no causal client identity. A pending manual mutation does not hold source - writes; a same-key source row can retain local attribution after rollback. - A subscriber can commit a truncate and a later same-key transaction during + mutation later fails. Attribution uses key and timing; `SyncConfig.write` has + no causal client identity. A pending manual mutation does not hold source + writes; a same-key source row can retain local attribution after rollback. A + subscriber can commit a truncate and a later same-key transaction during another drain. The suffix truncate can use the still-active mutation's - attribution. The later same-key transaction in that suffix drain is remote. - An active mutation on a key the - truncate does not write still attributes that key's first later transaction - in the same drain. Bounded histories also cross two - persisting same-key mutations, one or two source batches, both settlement - orders, and success/failure combinations. The first source batch is local - when at least one mutation succeeds; two failures leave it remote. A hostile + attribution. The later same-key transaction in that suffix drain is remote. An + active mutation on a key the truncate does not write still attributes that + key's first later transaction in the same drain. Bounded histories also cross + two persisting same-key mutations, one or two source batches, both settlement + orders, and success/failure combinations. The first source batch is local when + at least one mutation succeeds; two failures leave it remote. A mutant failed-sibling mutant fails the one-success cases at settlement. The pending - lane rejects a mutant that ignores pending mutations at its source or + grammar rejects a mutant that ignores pending mutations at its source or settlement publication. Bounded manual histories now cross success, direct - rollback, and handler rejection with both optimistic visibility settings. - A later disjoint edit cannot supply the earlier manual request's handler - outcome. The original harness left rejected manual requests pending; the - new settlement check rejects that harness and a shared-handler variant. - Six pending-peer histories distinguish same-key rollback cascades from - secondary rollback and different-key controls, through public settlement - and publication. Longer manual interleavings and larger same-key cascades - remain outside these fixed histories. The original truncate implementation - failed the reentrant history at failed-mutation settlement. The earlier - accepted-snapshot retention law - and its truncate-capture ownership refinement were retired with this - contract. The + rollback, and handler rejection with both optimistic visibility settings. A + later disjoint edit cannot supply the earlier manual request's handler + outcome. The original harness left rejected manual requests pending; the new + settlement check rejects that harness and a shared-handler variant. Six + pending-peer histories distinguish same-key rollback cascades from secondary + rollback and different-key controls, through public settlement and + publication. Longer manual interleavings and larger same-key cascades remain + outside these fixed histories. The original truncate implementation failed the + reentrant history at failed-mutation settlement. The earlier accepted-snapshot + retention law and its truncate-capture ownership refinement were retired with + this contract. The [PR #1907 review record](oracle-reviews/pr-1907-accepted-delete-ownership.md) - is historical. The [settlement-drop review record](oracle-reviews/2026-10-03-settlement-drop.md) - holds the RED/GREEN and mutant evidence. A handler that awaits a visibility receipt held by its own - transaction, such as an on-demand load of its own Collection, waits for - itself; that history is outside the generated grammar. + is historical. The + [settlement-drop review record](oracle-reviews/2026-10-03-settlement-drop.md) + holds the RED/GREEN and mutant evidence. A handler that awaits a visibility + receipt held by its own transaction, such as an on-demand load of its own + Collection, waits for itself; that history is outside the generated grammar. Issue #2071 adds two focused checks to this acceptance map: @@ -4016,38 +4051,38 @@ Issue #2071 adds two focused checks to this acceptance map: restart. The persisted wrapper owner needs the bridge callback and write sequence to reach that path. -- The [optimistic publication owner](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) +- The + [optimistic publication owner](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) distinguishes one and two queued same-key source transactions during a successful local mutation, two same-key writes inside one atomic transaction, an absent-key delete before a source insert, a delete and reinsert in one - atomic source transaction, a truncate published before - a mutation fails, pending manual mutations, two overlapping same-key - mutations, a truncate followed by another same-key transaction, and a - truncate followed by a different-key transaction with an active mutation in - one reentrant drain. The persisted wrapper receives the one/two transaction - pair. The source grammar can now preserve mixed write order inside one - transaction; its atomic delete/reinsert witness is distinguished from a - later same-key transaction. These histories establish bounded key-and-timing - attribution; they do not establish every mixed operation sequence, causal - authorship, or every overlapping mutation schedule. A - causal `$origin` guarantee would require an explicit source signal and a new - core-owner model and provider receiving witness. The - [PR #2078 review](oracle-reviews/pr-2078-origin-attribution.md) records the - original same-drain RED, the repair, and hostile controls. The optimistic - history owner still needs controlled pending-plus-persisting same-key - histories, truncate with two active owners, and longer reentrant or mixed - source suffixes if those broader schedules are claimed. Such a witness must - compare origin and complete publication cuts, not only the final row. The + atomic source transaction, a truncate published before a mutation fails, + pending manual mutations, two overlapping same-key mutations, a truncate + followed by another same-key transaction, and a truncate followed by a + different-key transaction with an active mutation in one reentrant drain. The + persisted wrapper receives the one/two transaction pair. The source grammar + can now preserve mixed write order inside one transaction; its atomic + delete/reinsert witness is distinguished from a later same-key transaction. + These histories establish bounded key-and-timing attribution; they do not + establish every mixed operation sequence, causal authorship, or every + overlapping mutation schedule. A causal `$origin` guarantee would require an + explicit source signal and a new core-owner model and provider receiving + witness. The [PR #2078 review](oracle-reviews/pr-2078-origin-attribution.md) + records the original same-drain RED, the repair, and mutant controls. The + optimistic history owner still needs controlled pending-plus-persisting + same-key histories, truncate with two active owners, and longer reentrant or + mixed source suffixes if those broader schedules are claimed. Such a witness + must compare origin and complete publication cuts, not only the final row. The persisted wrapper owner still needs the private bridge callback and write sequence before claiming that reported path. | Issue obligation | Implemented evidence | Limit | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Metamorphic laws | Includes cross-formulation/partition, D2 independent-key commutation, DBSP incremental/full recomputation, pagination provider/UI boundaries, optimistic snapshot stability | Equivalence premises are explicit; not arbitrary query rewrites. | -| Public observations | Reads, exact event payloads and reconstructed state, observer eligibility, downstream includes, lifecycle and ownership checks | Count/work budgets are separate from row truth and only pin established promises. | +| Public observations | Reads, exact event payloads and reconstructed state, observer eligibility, downstream includes, lifecycle and ownership checks | Count/work budgets are separate from row truth and only fix established promises. | | Meaningful async histories | Applied receipts, truncate/replay, cleanup/restart, pending optimistic work, held provider completion, leadership loss | Control real causes; unsupported SDK traces receive no coverage credit. | | Checker sensitivity | Faulty output controls, missing/duplicate events, stale completion/ownership controls, forced collisions and pre-fix runtime witnesses | Setup failures and timeouts are recorded separately from assertion kills. | -| Executed reach and replay | Named manifest with guarded replay, finite boundary products, pinned examples, fixed/random lanes and explicit stress runs | Root `test:oracles` is a selected core/Query DB campaign, not all repository oracles. | +| Executed reach and replay | Named manifest with guarded replay, finite boundary products, fixed histories, fixed/random campaigns and explicit stress runs | Root `test:oracles` is a selected core/Query DB campaign, not all repository oracles. | | Contract and scope records | Guide, companion case notes, this map, suite-local law/domain comments and architecture | This is an executable testing method, not a completeness proof or new product specification. | ## Running and replaying @@ -4087,7 +4122,7 @@ target execution, not reproduction of a particular bug. Local IVM and offline properties have separate environment variables; inspect their test headers. Do not assume the core multiplier reaches them. -The adapter-window properties have named replay routes in +The adapter-window properties have named fixed history routes in `packages/db/tests/oracle-replay-manifest.ts`. Each shared history property includes its production driver: `framework..window-history`, `framework..pending-window-history`, or @@ -4095,7 +4130,7 @@ includes its production driver: `framework..window-history`, `react-dependency-list`, `preact`, `vue`, `solid`, `angular`, and `svelte`. Each route selects its own conformance file and names the shared model module as its source. React and Preact render witnesses, all twelve React SSR -runtime/path cells, and the IVM numeric-window lane retain their own routes. +runtime/path cells, and the IVM numeric-window grammar retain their own routes. Selectors choose only the fresh or random campaign. The earlier three shared `framework.*` names are retired because they lost the failing driver's identity. The [replay review](oracle-reviews/2026-10-30-adapter-page-window-replay.md) @@ -4234,6 +4269,26 @@ keys and the fixed seed 271828 as removed, although both campaigns still run in the oracle file. Owner: `scripts.run-budget`. Needed witness: a campaign whose budget a helper computes and passes in as parameters. +`scripts/strip-oracle-probes-oracle.property.test.js` owns the build-time removal of the +oracle state probes from published builds (`scripts.strip-oracle-probes`, maintainer decision of +2026-11-09). Its grammar draws one-function programs with the guard in the placements that the +probe-form rule admits (function top, blocks, switch cases, sibling declarators, parenthesized forms, +unbraced and loop bodies, labels) and hostile or rule-rejected uses of `probing` and `__oracleProbe`. +The rule decides admission, and the model must agree with it on every draw. The reference is esbuild +with `process.env.NODE_ENV` defined as production. The production path is the plugin's transform, then +esbuild's type stripping. Both outputs run with a recording probe that must never be called. Owner: +`scripts.strip-oracle-probes`. Limits: one exported function per program, no imports, JSX, or bundler; +the host `process` is a stub with one environment value. The stripper refuses a value read, a binding, a +`for`-head guard, or a non-guard `probing` declaration, and every `__oracleProbe` that is left; a property name, +label, or type position named `probing` is accepted. `scripts/check-dist-probes.mjs` also rejects a surviving +`process.env.NODE_ENV` in a built package that wires the plugin; it is not an oracle. + +The gap report (`pnpm gap-report`) is the `oracle-gaps` CLI of +`@tanstack/oracles`. Its own oracles (`oracle-gaps.report`, and the static and +classify oracles) and their limits live in the TanStack/oracles repository, +under `packages/oracles/oracle-gaps/`. TanStack DB passes its prefix with +`--env-prefix TANSTACK_DB_ORACLE`. + `scripts/lint-scope-oracle.property.test.js` owns lint selection after an edit (`scripts.lint-scope`): the changed scope, the named (pre-commit) scope, and the Claude edit and stop hooks. Its model is a fixed-point closure over the @@ -4243,23 +4298,56 @@ also covers the checkout rule from #2280. A session works in the launch checkout or in a worktree nested under its `.worktrees/`. The hooks run the launch checkout's script with the session checkout as the input's `cwd`. They must select that checkout's files, and they must report through that -checkout's own copy of the rule. A plain scratch directory under `.worktrees/` -must produce no report. The repository's `.gitignore` is part of the tested -configuration. It does not draw `.js` support modules or drivers, although the -rule now resolves a `.js` test module when no TypeScript source exists for it -(the part-reach oracle owns that resolution). It passes file names in their +checkout's own installed copy of the rule. The rules come from +`@tanstack/eslint-plugin-oracles` under `scripts/oracle-lint-settings.mjs`, so +the lint configuration is the lockfile, which pins the plugin, and that +settings module: an edit to either selects every oracle file. A plugin change +is drawn as a lockfile edit together with a changed installed copy in the +session checkout's `node_modules`, as an install leaves them; a settings edit is +drawn as a change that alters no report. A plain scratch directory under +`.worktrees/` must produce no report. The repository's `.gitignore` is part of +the tested configuration. It does not draw `.js` support modules or drivers, +although the rule now resolves a `.js` test module when no TypeScript source +exists for it (the plugin's part-reach oracle, in TanStack/oracles, owns that +resolution). It does not draw an edit to `package.json` without a lockfile +change. It passes file names in their real-path spelling: a name given through a symlinked path is not covered. It does not generate a hook input without `cwd`, a worktree outside the launch checkout, or a worktree branch that has no copy of the script. In that last case the launch checkout's rule lints the worktree. -`packages/db/tests/declared-reach-oracle.property.test.ts` -(`oracle.declared-reach`; [review record](oracle-reviews/pr-2290-declared-reach.md)) -owns what `createOracleReach` (ORC-015) counts, when -it checks reach, and what fixed examples prove. It also owns the run -parameters that `oracleRandomParameters` and `oraclePropertyOptions` return, -and the campaigns that `runOracleCampaign` in -`packages/db/tests/oracle-campaign.ts` runs. +The oracle lint rules (`oracle/no-disabled`, `oracle/no-unit-test`, +`oracle/pure-model`, `oracle/replayable-seed`, and `oracle/probe-form`) are +`@tanstack/eslint-plugin-oracles`, from the TanStack/oracles repository, and +their self-tests moved there with them: the probe-form oracle (formerly +`lint.probe-form`, which owns the state-probe form +`probing && globalThis.__oracleProbe?.(name, condition)` under the +function-local `probing` guard of the 2026-11-08 decision, option B), the +part-reach oracle (formerly `lint.part-reach`), the replay-observation +oracle, and the rules' example tests, under +`packages/eslint-plugin-oracles/tests/`. Their ids left TanStack DB's property +registry, and their limits are recorded with them. TanStack DB keeps its +settings for the plugin in `scripts/oracle-lint-settings.mjs` (its environment +prefix and its replay implementation and witness modules), which +`eslint.config.js` and `scripts/agent-lint-feedback.mjs` both pass, and it +keeps `scripts/agent-lint-feedback.mjs` and +`scripts/check-oracle-run-counts.mjs`, whose oracles are +`scripts.lint-scope` and `scripts.run-budget` above. + +The oracle framework's own laws moved to `@tanstack/oracles` in +TanStack/oracles#1, with the self-tests that judge them. In the TanStack/oracles +repository, `packages/oracles/tests/declared-reach-oracle.property.test.ts` +(`oracle.declared-reach`; the earlier TanStack DB +[review record](oracle-reviews/pr-2290-declared-reach.md)) owns what +`createOracleReach` (ORC-015) counts, when it checks reach, and what fixed +examples prove. It also owns the run parameters that `oracleRandomParameters` +and `oraclePropertyOptions` return. `packages/oracles/tests/oracle-campaign.test.ts` +in the same repository owns the campaigns that `runOracleCampaign` runs. +TanStack DB no longer runs these self-tests: `packages/db/tests/oracle-reach.ts` +and `packages/db/tests/oracle-campaign.ts` only re-export the framework after +they load TanStack DB's configuration (`oracle-config.ts`). The laws below are +the ones those self-tests judge; their limits are recorded with them in +TanStack/oracles. - A history counts once per declared state it enters. A `reach()` call for an undeclared state, or before the first history, throws and counts nothing. @@ -4282,7 +4370,7 @@ and the campaigns that `runOracleCampaign` in ran than were declared, the check reports only that. - A replay keeps its seed, and a path replay of the property keeps its path and runs one history. `runOracleCampaign` runs a fixed-seed and a random - campaign without a prefix seed, one replay campaign with one, passes each + campaign without a prefix seed, one reseeded campaign with one, passes each its parameters, and reports each passing campaign on one `ORACLE_REACH` line. An invalid prefix environment throws. @@ -4291,8 +4379,8 @@ each reach prefix, and each driver option that a declared state needs, as examples, so their reach does not depend on the seed (#2287). `source-health.history` gets the same guarantee from `source-health.required-history`, which runs every reach prefix in each -campaign and shares its reach without fixed histories. The oracle generates -ledgers over two to four states, up to six histories with fixed marks, +campaign and shares its reach without fixed histories. The +`oracle.declared-reach` self-test in TanStack/oracles generates ledgers over two to four states, up to six histories with fixed marks, minimums up to three, up to four declared fixed histories, invalid options, undeclared and early reaches, an optional `replaying` option, a fixed campaign seed, and invalid campaign environments, under four run kinds: ordinary, @@ -4323,7 +4411,7 @@ RFC #1659 reviews found several green oracles whose stated laws remained valid but whose fixtures, grammars, or observations did not cover a neighboring boundary. Track the generalized repairs here instead of accumulating isolated regressions. Completion requires an executable owner, a production-path witness, -a hostile wrong-answer control, and an explicit statement of remaining limits. +a mutant wrong-answer control, and an explicit statement of remaining limits. - [ ] **Unchanged `NaN` keys at optimistic mutation admission.** With an eager Collection keyed by `row.id`, preload a row whose `id` is `NaN`, then @@ -4351,7 +4439,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. is available. A failed page, tie, or bounded repair, and an invalidation before any request has settled, still recover with a full-source read. A first attempt to make failures recover with a bounded prefix repair - broke 48 pinned expectations and showed real regressions: a wrong window + broke 48 fixed expectations and showed real regressions: a wrong window (`[1, 4]`, expected `[1, 2]`) after a failed tie request, changed lease release after a failed tie, and settlement mismatches in the pagination pending-history campaigns. Owner: the ordered-source-loader and pagination @@ -4461,7 +4549,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. | Gap recovery's cycle start, on `2047f4281` | 12 pullSince cells fail: an unreserved commit during pullSince rejected, and a reservation opened then straddled. Dropping the reservation check after pullSince fails 8. | | Gap recovery that reloads partway through, on `851e8d9da` | 28 cells fail: recovery advanced the stream position and applied a targeted delta before it deferred, so its retry skipped the reload. A gate only at recovery start still fails 15 scope-entry cells; reloads that start their own cycle inside recovery fail 61. | | `5649a07d4` | The open cells fail with `a persisted sync transaction cannot cross a hydration cycle`. Three `after read` reset follow-up cells also fail, because `main` applied a pre-reset commit before the reset set its term fence. Queueing resets with notices fixes this pre-existing defect. | - | Hostile mutants | Each mutant fails its cells, and every failure is an assertion. Deferring every notice fails the targeted change. Dropping the follower reply's wait fails the routed subset. Reporting hydration start before the check fails the aborted demand. A reset reload that starts its own cycle fails the first-publication reset. Applying a reset outside the notice queue fails the reset follow-ups. Retrying a waited notice while the reservation is open fails the follow-up and routed cells (repeated pullSince). Checking only at the mutex turn fails every scope-entry cell. Gating subset hydration only fails every reset, reload, and gap cell. Requeueing a waited notice behind later notices fails 12 reset follow-up cells: the pre-reset commit is published before its reset, and a reload is lost. | + | Mutant | Each mutant fails its cells, and every failure is an assertion. Deferring every notice fails the targeted change. Dropping the follower reply's wait fails the routed subset. Reporting hydration start before the check fails the aborted demand. A reset reload that starts its own cycle fails the first-publication reset. Applying a reset outside the notice queue fails the reset follow-ups. Retrying a waited notice while the reservation is open fails the follow-up and routed cells (repeated pullSince). Checking only at the mutex turn fails every scope-entry cell. Gating subset hydration only fails every reset, reload, and gap cell. Requeueing a waited notice behind later notices fails 12 reset follow-up cells: the pre-reset commit is published before its reset, and a reload is lost. | A routed load also judges law 7 at its settlement: the owner's row is public when the load settles. A reservation that ends between the follower's flush and its wait check is a one-microtask window no @@ -4563,14 +4651,14 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. the repair. Removing the reservation gate, or the `onError`, give-up, or signal-abort abandon, fails both campaigns. Each declared premise, including a peer commit after each kind of stop mid-transaction, is - reached in every campaign. Known gap #2267 is left out of the - generated histories and pinned with `it.fails`: a resumed tab that lags + reached in every campaign. Known limit #2267 is left out of the + generated histories and fixed with `it.fails`: a resumed tab that lags behind a peer can receive an update for a row the peer already deleted, and Electric's resume check puts it in `error`. The published Electric resume-safety contract requires rejecting an unknown partial row. Whether composition must avoid that condition without failing the source remains a separate contract question; - this repair does not remove the guard or resolve the pinned expectation. + this repair does not remove the guard or resolve the fixed expectation. The histories also post a peer `collection:reset` on the tabs' coordinator channel; no shipped host sends one yet, so this premise is controlled. The reset abandons every open reserved transaction, and @@ -4586,10 +4674,10 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. A delivery may also commit only a prefix of what a stream owes, as a server answering at an earlier moment does, so lagging commits are generated. The final checkpoint requires the server head (Law 2). - Known gap #2209 is one history that breaks it: a lagging tab + Known limit #2209 is one history that breaks it: a lagging tab commits an older row and closes for good. Generated histories never close a tab for good, and an `it.fails` replay through the same driver - and checks pins that history. + and checks that history. The [#2281 repair record](oracle-reviews/issue-2281-investigation.md) retains the row-loss and resurrection counterexamples on `82c5f999b`. A lagging source changed rows; another source advanced the shared resume @@ -4674,7 +4762,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. comparisons do not establish clean teardown. A generated cached-row regression also preserves intermediate observations during source-continuity repair. #2209's permanent-close case and #2267's - resumed-update gap remain pinned expected failures under this owner. + resumed-update gap remain fixed expected failures under this owner. A claimed leftover-replay/reset failure still needs a concrete schedule; the bounded two-replay-then-reset probe drains replay before reset. Live Electric/browser OPFS delivery needs the matching schedule in @@ -4696,7 +4784,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. - [x] **Real-provider conformance fixtures.** Frozen 15.2.7 React Native and Node receipts cover the supported peer version; 18.2.1 React Native, Node, and browser receipts cover the known forward shapes. Exact-row checks and a - row-dropping hostile control prove the shim accepts those envelopes without + row-dropping mutant control prove the shim accepts those envelopes without mutating them. Owner: `packages/react-native-db-sqlite-persistence/tests/fixtures/op-sqlite-provider-results.ts` and `packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver-oracle.test.ts`. @@ -4746,7 +4834,7 @@ a hostile wrong-answer control, and an explicit statement of remaining limits. same-database-handle admission law. - [x] **Startup generation interleavings.** Hold persisted startup between its metadata and hydration snapshots, then cross no write, a managed mutation, - and hostile raw loss. Compare public rows with the atomic durable snapshot, + and mutant raw loss. Compare public rows with the atomic durable snapshot, and require mutation persistence to wait until the prior stream position is known. Owners: SQLite resume snapshots and Electric resume snapshot races. - [x] **Schema-generation fences on cached adapters.** Cross a newer-schema @@ -4924,7 +5012,7 @@ Remaining limits: coverage relies on the source committing a load's rows before it settles that load (law 7); an unordered demand that loses one row and gains another keeps its row count and stays covered, so a row that retention removed can be missing from a covered restore (maintainer -decision, 2026-10-26; a fixed history pins the rule); and multi-tab +decision, 2026-10-26; a fixed history covers the rule); and multi-tab coordinators, outside the law by maintainer decision (2026-10-26). Part 4 now covers a tie at the window boundary, modelled as a limited demand plus an unordered tie-boundary demand, and page moves with and without an index. @@ -4997,8 +5085,8 @@ Solid, Vue, Svelte, and Angular witnesses run the covered, never-loaded, and failure histories through their own hook or primitive. With the contract's session-2 source forced to a 60 s wait and an upstream that never answers, every host and framework reports violations on its covered histories. The -OPFS witness also pins the guide's statement that a multi-tab coordinator -waits for the network; that run is recorded, not judged, because it pins the +OPFS witness also checks the guide's statement that a multi-tab coordinator +waits for the network; that run is recorded, not judged, because it fixes the guide rather than the law. Limits: no CI job runs the Expo, Capacitor, or Tauri e2e suites, and no host @@ -5036,25 +5124,25 @@ reload after a named notice keeps a seed's metadata (decision 18). | Owner | Production paths | Observations and checkpoint | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [sync metadata session oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-metadata-session-oracle.ts) (`flat`, `nested`, `seeds`, `nested-seeds`, `seeds-full`, `nested-conflicts` lanes) | `DbClient` Collection, core hold, `DbClient.hydrate` seeds | `metadata.row.get`, `metadata.collection.get`, `list()`, `list(prefix)`, `collection.base`, each commit receipt's state, thrown errors; after every action and after settlement | -| [persisted driver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts) (`persisted-flat`, `persisted-nested`, `persisted-seeds`, `persisted-cached`, `persisted-conflicts`, `persisted-nested-seeds`, `persisted-reads`, `persisted-reads-seeds`, `persisted-peers`, `persisted-peers-full`, `persisted-peers-on-demand`) | `persistedCollectionOptions` over an in-memory adapter, store hold, core hold, and a storage read held in flight; seeds through a `DbClient`, alone and crossed with nesting and a row cached in storage at startup; a peer's notices, eager, under full row updates, and on demand | the same source reads after every action; `collection.base` and receipts after settlement; in the peer lanes, stored rows after settlement and stored row metadata at every completed durable action cut and after settlement | -| [native SQLite/coordinator receiver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts) | two real Collections and coordinators, node:sqlite, BroadcastChannel and Web Locks; partial/full/on-demand lanes | shared model and source reads after correlated commit/notice/recovery cuts; rows, metadata and applied receipts after settlement; direct durable metadata after each correlated action and durable rows after settlement | +| [sync metadata session oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-metadata-session-oracle.ts) (`flat`, `nested`, `seeds`, `nested-seeds`, `seeds-full`, `nested-conflicts` grammars) | `DbClient` Collection, core hold, `DbClient.hydrate` seeds | `metadata.row.get`, `metadata.collection.get`, `list()`, `list(prefix)`, `collection.base`, each commit receipt's state, thrown errors; after every action and after settlement | +| [persisted driver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts) (`persisted-flat`, `persisted-nested`, `persisted-seeds`, `persisted-cached`, `persisted-conflicts`, `persisted-nested-seeds`, `persisted-reads`, `persisted-reads-seeds`, `persisted-peers`, `persisted-peers-full`, `persisted-peers-on-demand`) | `persistedCollectionOptions` over an in-memory adapter, store hold, core hold, and a storage read held in flight; seeds through a `DbClient`, alone and crossed with nesting and a row cached in storage at startup; a peer's notices, eager, under full row updates, and on demand | the same source reads after every action; `collection.base` and receipts after settlement; in the peer production paths, stored rows after settlement and stored row metadata at every completed durable action cut and after settlement | +| [native SQLite/coordinator receiver](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts) and its [campaigns](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts) | two real Collections and coordinators, node:sqlite, BroadcastChannel and Web Locks; partial/full/on-demand grammar | shared model and source reads after correlated commit/notice/recovery cuts; rows, metadata and applied receipts after settlement; direct durable metadata after each correlated action and durable rows after settlement | | [recovery schedules](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-metadata-recovery-oracle.ts) using the same session model and native receiver | response delivery, storage reading and captured-row application held separately; bare/non-bare seeds, a second tab's source write, or cleanup during one recovery on demand | source metadata before application, after the interruption and after completion; stale source reads at cleanup start and after late work; direct durable metadata after completion | | paired properties and boundary witnesses in the core driver file | production against itself: holds removed, row 2's actions removed, or seeds performed first; stale run; write without a transaction; cleanup with a held seed | source reads (and row 1's view) at each shared checkpoint, or every observation after settlement for seed order; thrown errors; unhandled rejections | -| [row metadata composition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts) held-window and nested lanes | Collection, core hold, a transaction begun inside an open one, `DbClient.hydrate` | implicit row-metadata rules during a hold, and for an outer transaction whose inserts are reclassified after an inner write applies first, with and without a hold | +| [row metadata composition oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts) held-window and nested grammar | Collection, core hold, a transaction begun inside an open one, `DbClient.hydrate` | implicit row-metadata rules during a hold, and for an outer transaction whose inserts are reclassified after an inner write applies first, with and without a hold | | [hydration authority oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/db-client-hydration-authority-oracle.test.ts) | `DbClient` initialData, hydration chunks, and an adapter sync transaction (inserts, updates, deletes, truncates, and metadata-only writes) in full and partial row modes | public rows, the synced store, and row metadata through `client.dehydrate()` after each action; D5 per object | | [Query ownership lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts) receiving witnesses | Query DB claims, releases, and retention cleanup over core and `persistedCollectionOptions` | published owners and non-owner row metadata | | receiving expectations in the [persisted oracle](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts), the Electric oracle, the Browser coordinator oracle, and the Electron IPC tests | persisted source inserts over a hydrated baseline; the durable mutation a source insert writes | public and durable rows; persisted mutation types | -On `30175be48`, 51 of 186 tests fail in the session, composition, and -retention files: held reads, commit order and outer writes after a held inner -commit, every seed rule, and the paired properties. The persisted lanes fail -the held reads through core, reads of an enclosing open transaction, an -insert's implicit clear, equal re-inserts, inserts over seeds and cached rows -under partial row updates, and late seeds beside a store-held transaction. -Six Query witnesses fail, including two that only dropping Query's `??` -metadata fallback repairs. The review record's evidence block lists reach -counts, mutants, and GREEN runs. +On `30175be48`, 51 of 186 tests fail in the session, composition, and retention +files: held reads, commit order and outer writes after a held inner commit, +every seed rule, and the paired properties. The persisted production path fails +the held reads through core, reads of an enclosing open transaction, an insert's +implicit clear, equal re-inserts, inserts over seeds and cached rows under +partial row updates, and late seeds beside a store-held transaction. Six Query +witnesses fail, including two that only dropping Query's `??` metadata fallback +repairs. The review record's evidence block lists reach counts, mutants, and +GREEN runs. Two comparisons are narrowed on purpose, each recorded with its authority in the review record: @@ -5068,37 +5156,38 @@ the review record: transaction: invalidation removes the whole transaction, row 1's writes included (decision 7). -The `persisted-peers` lane plays a second tab's source through the +The `persisted-peers` production path plays a second tab's source through the persistence coordinator: named row, metadata-only, delete, full-reload, and -reset notices, crossed with seeds, the cached row, nesting, and holds. A -reload that a notice causes is a storage read: its truncate replaces rows -without truncating source authority or row metadata, an unchanged row keeps -its status, and a seed's metadata survives a reload after a named notice -(decisions 9, 11, and 15 to 18). A reset is a source truncate, and every -later notice in its term reloads storage; on demand the reset keeps the -seeds this tab holds (decisions 19 and 20). After settlement the stored rows -must equal the model's, which a restart would read. -`persisted-peers-full` repeats the lane under full row updates, where an -update replaces its row in storage too; `persisted-peers-on-demand` repeats -it on demand with a demand for every row. A reset over metadata of a missing -row is too rare in generated histories to declare; a replay pins it. +reset notices, crossed with seeds, the cached row, nesting, and holds. A reload +that a notice causes is a storage read: its truncate replaces rows without +truncating source authority or row metadata, an unchanged row keeps its status, +and a seed's metadata survives a reload after a named notice (decisions 9, 11, +and 15 to 18). A reset is a source truncate, and every later notice in its term +reloads storage; on demand the reset keeps the seeds this tab holds (decisions +19 and 20). After settlement the stored rows must equal the model's, which a +restart would read. `persisted-peers-full` repeats the production path under +full row updates, where an update replaces its row in storage too; +`persisted-peers-on-demand` repeats it on demand with a demand for every row. A +reset over metadata of a missing row is too rare in generated histories to +declare; a fixed history covers it. An equal source re-insert keeps metadata without writing it (decisions 9 and 14). As with an update, that keep neither makes a DbClient seed durable nor sends it to peers (decision 18). Direct durable metadata is compared separately from this tab's source reads. The constructive durability grammar includes all 36 update/equal-reinsert/inner-reinsert × bare/non-bare seed × implicit/explicit -metadata × no-notice/named-notice/full-reload cases in each generated batch. Its partial/full/on-demand lanes run -through both the controlled persisted adapter and the real SQLite/coordinator -receiver, with 108 declared case/lane/notice states per host, each reached eight times -in both fixed and random campaigns. Seed row shape, seed timing and repeat -count vary; no campaign can omit the full reload. Explicit metadata writes are the -neighboring control. The original seed/re-insert/reload grammar, fixed/random -40 budgets and its completed fixed-branch guards remain; the new constructive -batch supplies guaranteed bare-key-1 implicit histories independently of seed. -Both hosts share the 40-run sibling and 8-run durability campaign mechanics in -`sync-metadata-session-campaign.ts`; production setup, recovery observations -and direct replay selection remain in their receiving files. +metadata × no-notice/named-notice/full-reload cases in each generated batch. Its +partial/full/on-demand grammar run through both the controlled persisted adapter +and the real SQLite/coordinator receiver, with 108 declared case/production +path/notice states per host, each reached eight times in both fixed and random +campaigns. Seed row shape, seed timing and repeat count vary; no campaign can +omit the full reload. Explicit metadata writes are the neighboring control. The +original seed/re-insert/reload grammar, fixed/random 40 budgets and its +completed fixed-branch guards remain; the new constructive batch supplies +guaranteed bare-key-1 implicit histories independently of seed. Both hosts share +the 40-run sibling and 8-run durability campaign mechanics in +`sync-metadata-session-campaign.ts`; production setup, recovery observations and +direct replay selection remain in their receiving files. The native on-demand path can receive a successful recovery response that crosses an earlier cache version without transaction replay details. The shared @@ -5177,35 +5266,36 @@ remains separate and open under this owner, with P8 investigating: property controlled-notice witness or the broader #2281 persistence reports. Remaining observation work under this owner: the read-hold and abandonment -lanes retain source-facing comparisons but do not provide the peer lanes' -direct durable transaction trace. A receiving witness must stage a wrong -durable write followed by repair while a read hold buffers a commit, including -abandonment, and reject it at the appropriate durable boundary. These additions -do not retire that obligation. Cross-host recovery schedules still need actual -Chromium OPFS and Electron IPC receiving witnesses; their owner remains this -session oracle with the broader receiving work tracked by #2272. +production paths retain source-facing comparisons but do not provide the peer +production paths' direct durable transaction trace. A receiving witness must +stage a wrong durable write followed by repair while a read hold buffers a +commit, including abandonment, and reject it at the appropriate durable +boundary. These additions do not retire that obligation. Cross-host recovery +schedules still need actual Chromium OPFS and Electron IPC receiving witnesses; +their owner remains this session oracle with the broader receiving work tracked +by #2272. Repaired counterexample, **META-CI-051-S001**: an enclosing update reads a cached row, an inner transaction deletes that row and sets its metadata while missing, then both commit. A named peer notice for another key drops the -retained metadata in the on-demand receiver, with and without a core hold. -Both the controlled adapter and native SQLite reproduce this source-read -failure under D16 before the production repair. The model correction separates -this run-only metadata from durable metadata when the outer update recreates the -row; the old model's false durable expectation masks the later source failure. -Owner: this session oracle. The generated -`sync-metadata-session.{persisted,sqlite}-missing-row-metadata` campaigns -cover twelve cases in all three peer lanes: implicit/explicit metadata, +retained metadata in the on-demand receiver, with and without a core hold. Both +the controlled adapter and native SQLite reproduce this source-read failure +under D16 before the production repair. The model correction separates this +run-only metadata from durable metadata when the outer update recreates the row; +the old model's false durable expectation masks the later source failure. Owner: +this session oracle. The generated +`sync-metadata-session.{persisted,sqlite}-missing-row-metadata` campaigns cover +twelve cases in all three peer production paths: implicit/explicit metadata, immediate/core-held application, a prior source truncate, same-key peer row or metadata replacement, unknown-write reloads, peer/source delete and source -truncate. All 36 case/lane states reach eight times in each fixed/random campaign -on both hosts. The wrapper distinguishes local metadata from a named peer -replacement, preserving D16 without defeating D11 or D15. Three wrong retention -policies fail the same source-read comparisons. The -[CI investigation](oracle-reviews/2026-11-02-p6-ci-missing-row.md) -records the pre-edit receiving RED, repaired GREEN, controls and bounded class -claim. This does not determine that the separate #2317/P8 witness has the same -cause or close its broader scope. +truncate. All 36 case/production path states reach eight times in each +fixed/random campaign on both hosts. The wrapper distinguishes local metadata +from a named peer replacement, preserving D16 without defeating D11 or D15. +Three wrong retention policies fail the same source-read comparisons. The +[CI investigation](oracle-reviews/2026-11-02-p6-ci-missing-row.md) records the +pre-edit receiving RED, repaired GREEN, controls and bounded class claim. This +does not determine that the separate #2317/P8 witness has the same cause or +close its broader scope. `persisted-reads-seeds` crosses read holds with seeds and the cached row and aborts a buffered commit's signal, which abandons it at the read's end @@ -5660,7 +5750,7 @@ callback counts, ordering or metadata attribution. See the [prep-review evidence](oracle-reviews/2026-11-05-boundary-only-prep-review.md). [The replacement evidence record](oracle-reviews/2026-10-05-compound-joins.md) -records RED admission on main, GREEN refinement, and hostile-mutant failures. +records RED admission on main, GREEN refinement, and mutant-mutant failures. The cold-join reconciliation owner now covers compound equality classes, nullish operands, two/three terms, order/reversal/nesting, all four join types, scan/index paths, controlled cold acquisition, and keyed replacement histories. @@ -5835,31 +5925,34 @@ The [sighted donor survey](oracle-reviews/2026-10-06-indexeddb-donor-survey.md) now has these receiving owners. The [port audit](oracle-reviews/2026-10-06-indexeddb-donor-port.md) records calibration, validation and the remaining limits. -- DP08: `compatibility.test.ts` (fixed tests; no oracle owner (tracked in #2264)) starts two/three native opens against - absent same/independent names, with identical complete declarations. It checks - schema, held restore/readiness, disjoint writes, convergence, restore and a later - upgrade with no orphan blocker. - `packages/db/e2e/indexed-db/value-oracle.spec.ts` receives three-open - histories in all engines. These histories settle all opens before writing; - they do not impose an order on open callbacks or union conflicting declarations. +- DP08: `compatibility.test.ts` (fixed tests; no oracle owner (tracked in + #2264)) starts two/three native opens against absent same/independent names, + with identical complete declarations. It checks schema, held + restore/readiness, disjoint writes, convergence, restore and a later upgrade + with no orphan blocker. `packages/db/e2e/indexed-db/value-oracle.spec.ts` + receives three-open histories in all engines. These histories settle all opens + before writing; they do not impose an order on open callbacks or union + conflicting declarations. - DP09: `persistence-values-oracle.test.ts` extends the persistence owner with independent tagged descriptions in `structured-clone-oracle.ts`. Seven value shapes cross scalar updates, peer delivery, export/import, first/middle/last - nested clone rejection, metadata rollback and fresh descriptor restore. - Type, offsets, backing bytes, dates and Blob content are observed inside the + nested clone rejection, metadata rollback and fresh descriptor restore. Type, + offsets, backing bytes, dates and Blob content are observed inside the receiving realm. Native receiving is in `packages/db/e2e/indexed-db/value-oracle.spec.ts`. - DP10: the compatibility owner substitutes six legal database/store names into - the same authored multi-store history. Prefix neighbors, typed keys in separate - transactions, version values, peers and restore distinguish routing mistakes. - `packages/db/e2e/indexed-db/host.spec.ts` (fixed tests; no oracle owner (tracked in #2264)) receives all six names - through single-transaction typed-key insertion, peer update, import, clear, export and fresh restore. + the same authored multi-store history. Prefix neighbors, typed keys in + separate transactions, version values, peers and restore distinguish routing + mistakes. `packages/db/e2e/indexed-db/host.spec.ts` (fixed tests; no oracle + owner (tracked in #2264)) receives all six names through single-transaction + typed-key insertion, peer update, import, clear, export and fresh restore. Prefix-neighbor and anchor snapshots must remain unchanged. - DP17: `packed-consumer.test.ts` supplements declaration tests with installed tarball ESM/CJS consumers and broken-export controls. The browser spec builds the same consumer without source aliases and executes it in all three engines. - Tarball overrides pin the installed dependency closure; registry range selection, - older Node/tool versions and every public export remain outside this smoke test. + Tarball overrides fix the installed dependency closure; registry range + selection, older Node/tool versions and every public export remain outside + this smoke test. The ordinary local Playwright WebKit profile rejects Blob preparation with `UnknownError`; its two Blob-containing cells require exact rejection and a @@ -6174,7 +6267,7 @@ artifacts because package exports otherwise resolve stale `dist` files. Reachable limits remain. A source that declares a restart capability must itself retire callbacks synchronously when recovery calls it. Claim expiry after core publication but before SQLite accepts the write rejects the receipt -and leaves the Collection in terminal error; a real-SQLite hostile probe +and leaves the Collection in terminal error; a real-SQLite adversarial probe confirmed the row can remain public without becoming durable. The ordinary durability-failure law permits that fail-stop, while automatic expiry reload covers admission before publication. The first new-format @@ -6228,16 +6321,19 @@ scheduling and a live Electric service rather than controlled HTTP remain separate unproved cuts; these fixed receivers do not close every cache-eviction interleaving. -The [cache-generation oracle audit](oracle-reviews/issue-2069-cache-generation-oracle-audit-53ded62d8.md) -records the generated grammar controls, killed mutants, direct replays, and -the remaining handoff limits for code commit `53ded62d8ef70ccd63b75d0e6dbc0a964f14b17c`. -The [cache-claim gap audit](oracle-reviews/issue-2069-cache-claim-gap-audit-575fb9473.md) -records the added receiving witnesses, hostile checks, direct replay, and -remaining limits for code commit `575fb9473dd7d8eccbe469d0831dc9f71cffddd4`. -The [native SQLite index receiving audit](oracle-reviews/issue-2069-native-ddl-receiver-b4d57ced6.md) -records the two reverse-order DDL witnesses, hostile checks, and remaining +The +[cache-generation oracle audit](oracle-reviews/issue-2069-cache-generation-oracle-audit-53ded62d8.md) +records the generated grammar controls, killed mutants, direct replays, and the +remaining handoff limits for code commit +`53ded62d8ef70ccd63b75d0e6dbc0a964f14b17c`. The +[cache-claim gap audit](oracle-reviews/issue-2069-cache-claim-gap-audit-575fb9473.md) +records the added receiving witnesses, mutant checks, direct replay, and +remaining limits for code commit `575fb9473dd7d8eccbe469d0831dc9f71cffddd4`. The +[native SQLite index receiving audit](oracle-reviews/issue-2069-native-ddl-receiver-b4d57ced6.md) +records the two reverse-order DDL witnesses, mutant checks, and remaining host-specific limit for code commit `b4d57ced63d513dd0dcb794b8e8bb2a840577758`. -The [follow-up review audit](oracle-reviews/issue-2069-native-ddl-review-followup-b42b94b73.md) +The +[follow-up review audit](oracle-reviews/issue-2069-native-ddl-review-followup-b42b94b73.md) records the exact recovery-settlement and target-table assertion repairs for code commit `b42b94b73ddba79a6a87fa93e71787dd126ea1bd`. @@ -6332,49 +6428,49 @@ and a demand started in that interval settles after its replacement subset applies. The ShapeStream callback schedule is controlled. An installed-SDK HTTP delivery at this exact intermediate cut and native-host execution are not established. -Applied-settlement owner: `packages/electric-db-collection/tests/electric-oracle.property.test.ts`. -Held optimistic application distinguishes readiness from subset completion for -demand both before and after readiness. A later same-run reset distinguishes a -current replacement obligation from a permanently fulfilled startup gate. -Concurrent initial-error and cleanup cuts observe settlement and lifecycle -authority; cleanup quietly settles in-flight adapter loads. A sibling-demand -abort cut requires one canceled demand to reject promptly while its sibling -and a replacement of the canceled demand wait for the full source snapshot and -succeed after its row applies. A separate external stream abort rejects its -current full-log demand and never certifies an identical later demand as loaded. -These full-log wait obligations still apply when scoped recovery is unavailable; -the scoped path uses SDK snapshots and applied receipts. -The changes-only path has the same refusal both when a subset transport returns -after stream retirement and when an applied receipt is held across that -retirement. -The full-mode retry histories now reject their pre-error demand, then require a -new demand to succeed after a same-run recovered snapshot, both with and -without a held applied receipt. An installed-SDK controlled-HTTP receiver -exhausts 503 backoff, invokes the user's retry -handler, and acquires a new demand while the retry HTTP request is open. It -requires that demand to wait, then succeed after the new full-log up-to-date. -A missing-header response checks that the SDK's refusal to retry rejects later -demands rather than leaving a renewed gate pending. The +Applied-settlement owner: +`packages/electric-db-collection/tests/electric-oracle.property.test.ts`. Held +optimistic application distinguishes readiness from subset completion for demand +both before and after readiness. A later same-run reset distinguishes a current +replacement obligation from a permanently fulfilled startup gate. Concurrent +initial-error and cleanup cuts observe settlement and lifecycle authority; +cleanup quietly settles in-flight adapter loads. A sibling-demand abort cut +requires one canceled demand to reject promptly while its sibling and a +replacement of the canceled demand wait for the full source snapshot and succeed +after its row applies. A separate external stream abort rejects its current +full-log demand and never certifies an identical later demand as loaded. These +full-log wait obligations still apply when scoped recovery is unavailable; the +scoped path uses SDK snapshots and applied receipts. The changes-only path has +the same refusal both when a subset transport returns after stream retirement +and when an applied receipt is held across that retirement. The full-mode retry +histories now reject their pre-error demand, then require a new demand to +succeed after a same-run recovered snapshot, both with and without a held +applied receipt. An installed-SDK controlled-HTTP receiver exhausts 503 backoff, +invokes the user's retry handler, and acquires a new demand while the retry HTTP +request is open. It requires that demand to wait, then succeed after the new +full-log up-to-date. A missing-header response checks that the SDK's refusal to +retry rejects later demands rather than leaving a renewed gate pending. The scoped reset history acquires a snapshot before must-refetch, then requires subset-end to apply its row before demand success without waiting for a full up-to-date. The neighboring full-mode reset retains that stronger wait. The -Electric durable fixture has an adversarial insert check that rejects retained -old columns or tag metadata; it is driver validation, not a new model owner. -The Electric adapter owner now holds an asynchronous user retry decision and -checks that a new full-recovery demand remains pending until source replacement, -while the pre-error demand rejects. It also checks caller abort before subset -transport settlement, while a request is queued behind the SDK cursor, and -while an applied receipt is held. Each original behavior failed at the caller -settlement cut; the fixed driver requires prompt `AbortError` without certifying -that demand. The explicit-resume neighbor keeps malformed durable metadata -untouched until source evidence arrives. The persistence owner now invokes a -retired scoped-recovery capability after cleanup and restart; the original -cleared the replacement Collection, and the lifecycle guard preserves its row. -These fixed schedules do not prove arbitrary overlapping retry decisions or -all failure paths through `startScopedRecovery`. A valid scoped-clear commit -failure with an exact public sync-error observation still needs a witness; -an attempted subscriber-throw fixture did not reach that callback during the -local clear and supplies no evidence for the review's error-report claim. +Electric durable fixture has an adversarial keys insert check that rejects +retained old columns or tag metadata; it is driver validation, not a new model +owner. The Electric adapter owner now holds an asynchronous user retry decision +and checks that a new full-recovery demand remains pending until source +replacement, while the pre-error demand rejects. It also checks caller abort +before subset transport settlement, while a request is queued behind the SDK +cursor, and while an applied receipt is held. Each original behavior failed at +the caller settlement cut; the fixed driver requires prompt `AbortError` without +certifying that demand. The explicit-resume neighbor keeps malformed durable +metadata untouched until source evidence arrives. The persistence owner now +invokes a retired scoped-recovery capability after cleanup and restart; the +original cleared the replacement Collection, and the lifecycle guard preserves +its row. These fixed schedules do not prove arbitrary overlapping retry +decisions or all failure paths through `startScopedRecovery`. A valid +scoped-clear commit failure with an exact public sync-error observation still +needs a witness; an attempted subscriber-throw fixture did not reach that +callback during the local clear and supplies no evidence for the review's +error-report claim. Remaining witnesses before broader closure: @@ -6596,28 +6692,29 @@ broad oracle assertion, not a production repair. ### RC Effect explicit source metadata (2026-10-28) -Owner: `packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts`, sharing -the small `virtual-row-fields-source-oracle.ts` source judgment with the root -metadata owner. It recomputes explicitly selected ordinary metadata fields and -full before/after deltas independently, then receives actual Effect onBatch and -named callbacks across 200 finite single-source, LEFT-join and populated-union -histories. It preserves previous values, multiplicity, field absence/undefined, -typed source keys, and opaque event-key continuity only within active intervals. -Held mutations, independent confirmations/outcomes, both settlement orders, -remote updates, peer exit/reentry and next-use reach 5,512 lane/channel/cut cells -exactly once. No implicit Effect-root metadata or output Collection ID is claimed. - -Original P09 evidence: all 64 LEFT-join histories emitted a startup update for -a never-entered unmatched occurrence after its load finished. Those initial +Owner: `packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts`, +sharing the small `virtual-row-fields-source-oracle.ts` source judgment with the +root metadata owner. It recomputes explicitly selected ordinary metadata fields +and full before/after deltas independently, then receives actual Effect onBatch +and named callbacks across 200 finite single-source, LEFT-join and +populated-union histories. It preserves previous values, multiplicity, field +absence/undefined, typed source keys, and opaque event-key continuity only +within active intervals. Held mutations, independent confirmations/outcomes, +both settlement orders, remote updates, peer exit/reentry and next-use reach +5,512 production path/channel/cut cells exactly once. No implicit Effect-root +metadata or output Collection ID is claimed. + +Original P09 evidence: all 64 LEFT-join histories emitted a startup update for a +never-entered unmatched occurrence after its load finished. Those initial comparisons were RED on unchanged `858b174a6`; histories continued and all later -comparisons passed. -The other 136 native histories and 16 controls pass. Independent hostile -overlays reject dispatch, previous-value, duplication, colliding-key and extra -undefined defects; lawful key renaming, fresh reentry and empty partitions retain -the baseline. Per-lane omissions and seeded partial selections now fail reach. -The finite owner explicitly disables the irrelevant shared replay exemption; -O26 retains a broader campaign inventory for that harness class. See chunk 16 -in the RC chunk record for exact fingerprints, evidence and review. +comparisons passed. The other 136 native histories and 16 controls pass. +Independent mutant overlays reject dispatch, previous-value, duplication, +colliding-key and extra undefined defects; lawful key renaming, fresh reentry +and empty partitions retain the baseline. Per-campaign omissions and seeded +partial selections now fail reach. The finite owner explicitly disables the +irrelevant shared replay exemption; O26 retains a broader campaign inventory for +that harness class. See chunk 16 in the RC chunk record for exact fingerprints, +evidence and review. Limits: eager controlled core sources, scalar rows, one mutation per source, and these named paths. No real provider/framework, arbitrary scheduling, @@ -6766,8 +6863,13 @@ explicit factory and inferred factory entry points, with/without schema and Query rows/envelope select.36 consumers contain4,212 cells; all702 declared states reach six histories. Promise accepts void/undefined and rejection, including a synchronous never result; fulfilled values and synchronous void -are rejected. Six controls calibrate missing/duplicate/misattributed diagnostics. -All42 tests pass on unchanged858b174a6. Independent compiler probes reject +are rejected. It is the `oracleProperty` `adapter.handler-types`: each shape +resolves to a generated consumer program, the TypeScript compiler is the +driver, and six calibrations (missing, duplicate, setup-error, rejected-void, +unrelated same-code, and body-local Promise diagnostics) must each fail +their named law. Its enumerated reach is no longer asserted per state (open: +enumerated sources declare no reach yet). +All42 tests pass on unchanged858b174a6; the `oracleProperty` port registers 43 (it adds a determinism test). Independent compiler probes reject unrelated same-code and body-local Promise errors substituted for handler errors. Source imports do not establish packed declarations or npm consumers; O22 owns that receiving boundary. Full evidence is in the RC chunk record. @@ -6862,8 +6964,10 @@ standalone recorder checks in `packages/db/tests/oracle-replay.test.ts`; the needed witness is an explicit writer-failure policy comparison for that older API. The finite wrapper's guarantee does not settle that policy conflict. -The lint observation owner is -`scripts/eslint-plugin-oracle/replay-observation-oracle.test.js`. Its independent +The lint observation owner was +`scripts/eslint-plugin-oracle/replay-observation-oracle.test.js`; it moved with +the lint rules to `packages/eslint-plugin-oracles/tests/` in TanStack/oracles, +where its current limits are recorded. At that move, its independent source-intent model and actual ESLint receiver distinguish typed completed-run output from literal generated-run budgets. All 3,848 source programs and two checker controls pass; 1,924 states each reach twice. With ordinary rule/harness @@ -7087,13 +7191,14 @@ configurable/nonconfigurable source data. Authored nested objects replace their field; dotted aliases preserve siblings without writing through source objects. The four Core paths supply 120 path histories and 360 result cuts. Fourteen real framework groups supply another 336 histories and 1,008 result cuts. All compare -current and retained source rows and require the actual source descriptor at -all three stages. Existing 819 and 1,200 observations and the earlier receiving +current and retained source rows and require the actual source descriptor at all +three stages. Existing 819 and 1,200 observations and the earlier receiving histories remain. Unkeyed functional projections are not legal derived-identity -framework inputs, so they receive no framework handoff credit. Legal fresh-object -expression controls provide those neighboring receiving comparisons instead. -See the [second review record](oracle-reviews/2026-10-30-solid-oracle-review-round-two.md) -for complete intake, RED gates, GREEN, hostile variants, and bounded closure. +framework inputs, so they receive no framework handoff credit. Legal +fresh-object expression controls provide those neighboring receiving comparisons +instead. See the +[second review record](oracle-reviews/2026-10-30-solid-oracle-review-round-two.md) +for complete intake, RED gates, GREEN, mutant variants, and bounded closure. The same review adds six ordinary/subclass-array overlap cells. Dotted writes select lengths zero, one, and two while current and retained source arrays keep @@ -7163,17 +7268,17 @@ unequal payloads. A last-input retraction mutant and a reused negative-key mutant fail the wider check while passing the prior two-member owner. `includes-oracle.property.test.ts` extends its existing full-tree recompute -owner with colliding numeric-group route strings and the shared distinct -support history grammar. Inline arrays and Collection facades each run -fixed/random 100-history campaigns and compare public rows after each applied -source transaction. The original operator fails Collection facade membership -at checkpoint 2. Inline arrays pass: `createActiveBuckets` is used only for +owner with colliding numeric-group route strings and the shared distinct support +history grammar. Inline arrays and Collection facades each run fixed/random +100-history campaigns and compare public rows after each applied source +transaction. The original operator fails Collection facade membership at +checkpoint 2. Inline arrays pass: `createActiveBuckets` is used only for Collection-valued includes. This is a receiving witness for route coexistence, shared support, partial/last removal and reactivation, not a claim about every facade lifecycle observation. Four-member buckets are exercised at D2; the -public includes witness has two colliding routes and one anchor. Neither lane -claims mutable values, arbitrary payload kinds, framework scheduling or -intermediate source-transaction publication cuts. +public includes witness has two colliding routes and one anchor. Neither +production path claims mutable values, arbitrary payload kinds, framework +scheduling or intermediate source-transaction publication cuts. Implicit distinct keys across separate builds remain unspecified (maintainer decision in the 2026-10-30 review); applications needing stable keys supply @@ -7313,7 +7418,26 @@ contains per-mode reach, calibration, replay and grammar accounting. ### Adapter-owned page windows -The framework owner includes [window-history-oracle.ts](../../packages/db/tests/conformance/window-history-oracle.ts), registered through the shared conformance suite in React, Preact, Vue, Solid, Angular and Svelte. Maintainer decision 2026-10-30 for #2291: changing only `.limit()`/`.offset()` moves the existing outer window; it adds no hook option. The independent sort/filter/slice model checks ordered selected rows and keyed-map values after each framework flush, Collection retention for page moves, replacement for source/filter/order/projection/nested-window changes, and retained upstream ownership. Direct and nested paths have separate reach, as do changing/stable/opaque keys where the driver supports keys. Keyed source swaps include distinct source objects under one Collection ID. Generated source updates check liveness after reuse. The bare-callback lanes also vary a Collection option together with a page change and check its applied value. The pending lane observes a move during initial loading, oldest settlement while the latest remains pending, initial readiness, overlapping held acquisitions, final latest rows, and unmount before settlement. The publication-callback lane changes offset, limit or both from a public change callback, then checks the retained Collection after the host settles. It controls demand settlement over already installed source rows; the core pagination owner continues to own source delivery, failures and ordered repair. +The framework owner includes +[window-history-oracle.ts](../../packages/db/tests/conformance/window-history-oracle.ts), +registered through the shared conformance suite in React, Preact, Vue, Solid, +Angular and Svelte. Maintainer decision 2026-10-30 for #2291: changing only +`.limit()`/`.offset()` moves the existing outer window; it adds no hook option. +The independent sort/filter/slice model checks ordered selected rows and +keyed-map values after each framework flush, Collection retention for page +moves, replacement for source/filter/order/projection/nested-window changes, and +retained upstream ownership. Direct and nested paths have separate reach, as do +changing/stable/opaque keys where the driver supports keys. Keyed source swaps +include distinct source objects under one Collection ID. Generated source +updates check liveness after reuse. The bare-callback grammar also vary a +Collection option together with a page change and check its applied value. The +pending grammar observes a move during initial loading, oldest settlement while +the latest remains pending, initial readiness, overlapping held acquisitions, +final latest rows, and unmount before settlement. The publication-callback +grammar changes offset, limit or both from a public change callback, then checks +the retained Collection after the host settles. It controls demand settlement +over already installed source rows; the core pagination owner continues to own +source delivery, failures and ordered repair. The [React receiving witness](../../packages/react-db/tests/window-render-oracle.test.tsx) reuses this model/checker at suspended, cancelled and committed render cuts for eager and finite on-demand sources, with derived and explicit keys. It checks the captured committed Collection and provider acquisition count while a render is abandoned. The [Preact receiving witness](../../packages/preact-db/tests/window-render-oracle.test.tsx) checks an aborted render under a real error boundary while a public subscription keeps the committed Collection active. Both run separate offset-only, limit-only and combined-move campaigns with declared reach per category. These do not establish arbitrary concurrent framework schedules. The retained-work observation checks continuous upstream ownership and every public subscription-count transition during a retained move; add-before-remove replacement fails, while retired Collections may complete GC. This is not a bound on CPU time or every possible internal compilation strategy. The page law now runs through client-backed React and Svelte drivers, and React Suspense both with and without a client (maintainer decision, 2026-11-03, issue #2345). DbClient is a driver mode, not a React exception; future client-backed adapters register the same histories and comparisons. Each mode receives direct/nested, applicable keyed/derived, pending, publication-callback, changed-plan and source-write histories. Every adapter also runs the independent-peer histories: equal queries yield distinct outputs; declarative and imperative moves preserve the peer page; source writes update both; retiring one leaves its peer usable. Before initial Suspense readiness, the pending comparison observes the actual fallback and absence of a successful result. It starts retention comparisons at the first successful return. @@ -7379,7 +7503,15 @@ Evidence, per-path reach counts, reviewer reconciliation and mutation outcomes a The [fresh independent review](oracle-reviews/2026-10-30-adapter-page-windows-round-2.md) records observation and failure-fidelity repairs. Shared and receiving campaigns preserve the first violated law, checkpoint and input trace through shrinking. Later action and cleanup failures remain distinguishable; non-reproducing shrink candidates supply no successful product evidence. The original late-coverage process gap remains documented. -The existing [top-K support-window owner](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts) also has a numeric-window lane for safe-integer maximum offsets and widths, exact omitted-bound inheritance on partial moves, and required demand messages when installed ranges are empty. It permits additional messages. It observes one-group settled operator rows, size and demand signals; real framework source growth belongs to the shared receiving owner above. [Numeric evidence](oracle-reviews/2026-10-30-topk-numeric-windows.md) records RED, GREEN, reach, replay and mutants. +The existing +[top-K support-window owner](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts) +also has a numeric-window grammar for safe-integer maximum offsets and widths, +exact omitted-bound inheritance on partial moves, and required demand messages +when installed ranges are empty. It permits additional messages. It observes +one-group settled operator rows, size and demand signals; real framework source +growth belongs to the shared receiving owner above. +[Numeric evidence](oracle-reviews/2026-10-30-topk-numeric-windows.md) records +RED, GREEN, reach, replay and mutants. ### Projection and negative-window gaps from removal review (2026-11-02) @@ -7392,9 +7524,15 @@ rows and both data-first/Collection-first reads are checked at each driver's native parameter cut, then after settlement. Finite singleton queries retain identical offset/limit across projection changes, including positive offsets. Native cuts require raw shape; settled cuts also compare selected values and -keyed state. This does not promise synchronous row freshness or identical host -scheduling. Disabled inputs, errors, supplied Collections, DbClient and broad -host/AOT behavior remain outside this lane. +keyed state. It is one `oracleProperty` definition, registered by each +framework's conformance suite with that framework's driver as +`framework..result-shape`; two constructed histories enter its twelve +required states on every production path, the former fixed examples are fixed +histories, and the previous-mode projection and stale rows are calibrations. +Reintroducing the Angular defect below fails 32 of its tests, each at law +`current query projection`. This does not promise synchronous row freshness or +identical host scheduling. Disabled inputs, errors, supplied Collections, +DbClient and broad host/AOT behavior remain outside this grammar. **Repaired receiving defect:** Angular previously returned the old query's raw projection at its synchronous Signal read before effects. Both directions @@ -7416,7 +7554,7 @@ cardinality. Signed rows, order, values, multiplicity and size remain observed. The direct and orderBy size getters also count with an always-true predicate, which must equal the selected row count. Negative widths, fractional bounds, multiple groups, varying prefix predicates and downstream acquisition remain -outside this lane; existing support/numeric budgets and their prior evidence +outside this grammar; existing support/numeric budgets and their prior evidence remain unchanged. The negative-width question has a maintainer decision (2026-11-02): public @@ -7451,7 +7589,7 @@ raw authored IR, framework error timing and provider delivery are outside the new admission evidence. Existing findOne, order and reentrancy error precedence remains separate. All 24 fixed/random admission campaigns reach every declared state in 21 histories each; unchanged production fails all six negative -receivers, and eight hostile mutations fail the intended comparisons. See the +receivers, and eight mutant mutations fail the intended comparisons. See the [admission evidence](oracle-reviews/2026-11-02-negative-limit-admission.md). **Repaired operator defects:** negative-window rows, size, source edits and @@ -7560,9 +7698,10 @@ campaign measured 58 divergence repairs, of which 57 lacked that overwrite premise and one had it (81 initiated/80 accepted histories). Earlier final "lagging rewrite" counts are historical divergence evidence. A fourth terminal regime constructs the actual overwrite. Original arbitrary/reset/stop regimes, -24-step prefix, 8–24-step arbitrary tail, models, comparisons, known gaps, -80-run budgets and minima remain. New lagging suffix length is 12/15 for two/three -tabs. Final commit omission fails the ordinary durable/public comparison. +24-step prefix, 8–24-step arbitrary tail, models, comparisons, known limits, +80-run budgets and minima remain. New lagging suffix length is 12/15 for +two/three tabs. Final commit omission fails the ordinary durable/public +comparison. The deferred-acquisition owner adds 32 finite histories over a real same-source LEFT self-join, crossing subscriber/preload, one/two rows, literal/Promise diff --git a/docs/contributing/oracle-rubric.md b/docs/contributing/oracle-rubric.md index df249ca78d..398e2c55ce 100644 --- a/docs/contributing/oracle-rubric.md +++ b/docs/contributing/oracle-rubric.md @@ -143,7 +143,7 @@ presence of a preferred heading, class, comment template, or helper. - **Trigger:** An important generated property or a claimed oracle repair. - **Obligation:** The evidence MUST name at least one plausible wrong answer or design and demonstrate that the relevant comparison rejects it. -- **Acceptance evidence:** A wrong-result control, hostile fixture, fault +- **Acceptance evidence:** A wrong-result control, mutant fixture, fault injection, or production mutant reaches the intended checkpoint and preserves the distinguishing failure. - **Not required:** Modifying production, checking in a mutant, or running a @@ -157,8 +157,8 @@ presence of a preferred heading, class, comment template, or helper. - **Trigger:** An important generated property. - **Obligation:** The package's oracle campaign MUST execute the same property, - generators, observation recorder, refinement check, and run budget twice: - once with a documented fixed seed and once without a seed. A replay interface + generators, observation recorder, refinement check, and run budget twice: once + with a documented campaign seed and once without a seed. A replay interface MUST accept both the seed and shrink path and MUST run the requested replay directly. When the property uses `fc.commands`, the interface MUST also accept the commands arbitrary's reported `replayPath` and pass it back to @@ -167,7 +167,7 @@ presence of a preferred heading, class, comment template, or helper. campaign records its execution, and a checked replay command reproduces a captured seed-and-path failure, including the command replay path when the property uses `fc.commands`. -- **Does not satisfy:** Fixed examples do not replace the fixed-seed campaign. +- **Does not satisfy:** Fixed histories do not replace the fixed-seed campaign. Different generators, observation recorders, refinement checks, or budgets do not establish campaign parity. - **Not required:** Running either normal campaign before an explicit replay. @@ -227,21 +227,21 @@ presence of a preferred heading, class, comment template, or helper. ### ORC-012: Review evidence -- **Trigger:** A change claims that an oracle is new, repaired, or comprehensively - audited against this guide. +- **Trigger:** A change claims that an oracle is new, repaired, or + comprehensively audited against this guide. - **Obligation:** The review evidence MUST record the outcome of every other - applicable numbered requirement and MUST identify the requirements that do - not apply. + applicable numbered requirement and MUST identify the requirements that do not + apply. - **Acceptance evidence:** Each applicable requirement has concrete evidence or - an explicit unresolved gap. Each non-applicable requirement has a reason tied - to its trigger. + an explicit unresolved limit. Each non-applicable requirement has a reason + tied to its trigger. - **Conditional bug-class closure:** When a change claims to eliminate a bug class, the review evidence MUST name its contract × history × production-path × observation boundary, identify the original and adjacent distinguishing witnesses, show a relevant wrong design rejected at the intended checkpoint, - and list unresolved in-scope cells with their coverage-map owner. A known - reachable counterexample prevents a closure claim. One oracle need not own - every boundary. + and list unresolved in-scope cells with their coverage-map oracle owner. A + known reachable counterexample prevents a closure claim. One oracle need not + own every boundary. - **Evidence location:** At closeout, verdict-critical evidence outside the executable oracle MUST live in a versioned review record tied to the exact reviewed head. An editable pull-request description alone does not satisfy @@ -257,8 +257,9 @@ presence of a preferred heading, class, comment template, or helper. - **Obligation:** The evidence MUST show a legal witness that reaches the law's premise and a nearby in-domain witness whose observation at the claimed checkpoint distinguishes the stated law from a plausible wrong boundary or - consequence. If those witnesses are unavailable, the claim MUST be limited - to the tested cases, and the reusable-law gap and its owner MUST be recorded. + consequence. If those witnesses are unavailable, the claim MUST be limited to + the tested cases, and the reusable-law limit and its oracle owner MUST be + recorded. - **Acceptance evidence:** The test or review record names the wrong rule and shows which existing case rejects it. For a numeric threshold, cases on opposite sides are insufficient if a plausible misplaced threshold still @@ -273,7 +274,7 @@ presence of a preferred heading, class, comment template, or helper. provider or host. - **Obligation:** The evidence MUST identify that premise and either point to a receiving witness in which the real provider or host supplies the same - premise, or mark the cross-boundary claim unresolved with an owner. + premise, or mark the cross-boundary claim unresolved with an oracle owner. - **Shared law:** When the controlled and real environments check the same law, the receiving witness SHOULD be another production driver for the same oracle. It @@ -312,15 +313,15 @@ presence of a preferred heading, class, comment template, or helper. - **Trigger:** A file defines an oracle, its input grammar, or a generated campaign, including a compile-time type oracle. -- **Obligation:** The file MUST NOT contain ordinary example-based unit tests - or standalone concrete type assertions. Fixed regressions MUST be inputs to - the same independent model, production driver, and comparison used by the - broader campaign. They MUST NOT restate their expected answers. A type - oracle MUST apply a reusable independent type law to generated legal inputs. +- **Obligation:** The file MUST NOT contain ordinary example-based unit tests or + standalone concrete type assertions. Fixed regressions MUST be inputs to the + same independent model, production driver, and comparison used by the broader + campaign. They MUST NOT restate their expected answers. A type oracle MUST + apply a reusable independent type law to generated legal inputs. - **Acceptance evidence:** A reviewer can vary the input without adding an - expectation. Every retained regression and receiving driver uses the shared - judgment. Checker calibration supplies deliberately wrong observations to - that judgment. + expectation. Every retained regression and receiving production driver uses + the shared judgment. Checker calibration supplies deliberately wrong + observations to that judgment. - **Does not satisfy:** Renaming a unit test, adding contract prose, enumerating fixtures with separate assertions, or wrapping fixed expectations in a property runner. @@ -347,7 +348,10 @@ Before writing the loop, answer six questions: | What will we compare, and when? | Rows, order, events or other promised behavior, at a named checkpoint. | | Why should we trust this check? | Evidence the path ran, the comparison rejects the relevant wrong answer, and failures can be replayed. | -These are responsibilities, not six required classes. A small property may answer them in a comment and twenty lines of code. A lifecycle suite may need a separate model and driver. Do not build a framework just to fill the table. +These are responsibilities, not six required classes. A small property may +answer them in a comment and twenty lines of code. A lifecycle suite may need a +separate model and production driver. Do not build a framework just to fill the +table. An evidence card is an optional way to collect this information; it is not a required file format: @@ -398,10 +402,10 @@ imports nothing so that browser and native-app modules can use it: the check compares production observations with the model or with a second formulation, and the grammar is the generators that produce legal histories. Each part is optional. Every test in the file uses a declared part, directly -or through a driver: +or through a production driver: - A generated property or a fixed history runs through the check. -- A model pin steps the model and checks one rule against its authority. +- A fixed history steps the model and checks one rule against its authority. - A calibration shows that the check rejects a wrong answer (ORC-006). - A grammar calibration shows that the generators reach the states that the laws need (ORC-004, ORC-015). @@ -430,21 +434,23 @@ module is linted on its own, so the rule does not report a call to it. An rule judges a call to its helpers as it judges an ordinary module's. ORC-016 keeps ordinary unit tests and concrete type assertions out of oracle -files. Fixed regressions replay the shared law; supplemental examples live in -ordinary test files. +files. Fixed regressions run through the shared law; supplemental examples live +in ordinary test files. An ordinary example or regression does not need this suffix merely because it asserts a contract. The same applies to concrete type assertions, fixtures, -registration wrappers, and generic runner utilities. A driver that only invokes -production or records observations remains support. Coverage-map inclusion, -parameterization, and contract prose alone do not establish oracle ownership. - -Count owners, model/checker definitions, drivers, and other support separately. -Several files may execute one shared oracle, and one file may contain several -oracle mechanisms. A filename is a discovery aid, not evidence of guide -conformance. Preserve runner suffixes such as `.property.test.ts`, `.test-d.ts`, -and `.spec.ts`. Update imports, test commands, replay selectors, and current -coverage links whenever an oracle file moves. +registration wrappers, and generic runner utilities. A production driver that +only invokes production or records observations remains support. Coverage-map +inclusion, parameterization, and contract prose alone do not establish oracle +ownership. + +Count oracle owners, model/checker definitions, production drivers, and other +support separately. Several files may execute one shared oracle, and one file +may contain several oracle mechanisms. A filename is a discovery aid, not +evidence of guide conformance. Preserve runner suffixes such as +`.property.test.ts`, `.test-d.ts`, and `.spec.ts`. Update imports, test +commands, replay selectors, and current coverage links whenever an oracle file +moves. ### Keep five responsibilities distinguishable @@ -608,19 +614,20 @@ rather than a generic session, state, owner, or generation. When production renames a concept, update its glossary entry and the models that represent it in the same change. -### Audit prose, model, driver, and observations together +### Audit prose, model, production driver, and observations together Treat the layers as separate claims. Compare each pair during review: 1. Does every promise in the prose have a model rule? 2. Does the model retain every distinction that a legal next action can expose? 3. Can the history grammar reach each modeled transition? -4. Does the driver exercise the named production path and event boundary? +4. Does the production driver exercise the named production path and event + boundary? 5. Does each modeled output reach an assertion at the promised checkpoint? 6. Can the recorder represent duplicate, missing, reordered, or partial output? 7. Does any assertion impose behavior that the contract and model do not state? -8. Do prose, model, driver, and production use the glossary's canonical term - and transition grammar for each shared concept? +8. Do prose, model, production driver, and production use the glossary's + canonical term and transition grammar for each shared concept? 9. Does every model-only term declare how it maps to production, or that it has no production counterpart? @@ -628,8 +635,8 @@ This comparison is a useful audit instrument. ORC-012 requires outcomes for the applicable numbered requirements, not these questions or their answers in the test file. If the prose cannot explain an assertion through the model, the model may be incomplete. If the model predicts a fact that the test never observes, -the test may be false green. If the driver cannot create a named phase, the -prose claims more reach than the test has. +the test may be false green. If the production driver cannot create a named +phase, the prose claims more reach than the test has. ### Keep the prose proportional @@ -699,7 +706,8 @@ Take two rows: `(id: 1, rank: 2)` and `(id: 2, rank: 1)`, with width two. The ex That is a history: a starting state followed by actions. The generator can produce many such histories while the same reference computes each answer. -The integration loop below is pseudocode; the named driver operations are not TanStack APIs: +The integration loop below is pseudocode; the named production driver operations +are not TanStack APIs: ```text create independent model and production fixture @@ -727,18 +735,22 @@ After choosing an action kind, choose its key from the applicable set. This is c Shrinking means reducing a failing input or action list while keeping its failure. Design action dependencies so the smaller histories remain meaningful; don't disable shrinking merely because the test is stateful. The [fast-check model guide](https://fast-check.dev/docs/advanced/model-based-testing/) describes commands and replay; check the installed version before copying an API recipe. -Pin the structural cases that matter: no rows, width zero, a boundary tie, and repeated changes to one key. Exhaust a small domain where that is cheap. Then randomize values and longer legal histories. Fixed cases, bounded enumeration and random exploration do different jobs; overlap between them is not a defect. +Include fixed histories for the structural cases that matter: no rows, width +zero, a boundary tie, and repeated changes to one key. Exhaust a small domain +where that is cheap. Then randomize values and longer legal histories. Fixed +cases, bounded enumeration and random exploration do different jobs; overlap +between them is not a defect. ### Run fixed and random campaigns with direct replay -A fixed fast-check seed always generates the same cases. This makes a useful +A fixed campaign seed always generates the same cases. This makes a useful history stable, but it does not explore new histories on later runs. Do not describe a fixed-seed property as random coverage. Under ORC-007, every important generated property runs in two campaigns: -1. Run a fixed seed that preserves a known useful campaign. -2. Run without a seed so fast-check chooses a new seed. +1. Run a fixed campaign seed that preserves a known useful campaign. +2. Run without a campaign seed so fast-check chooses a new seed. When the random campaign fails, retain the reported seed and shrink path. Give the suite environment variables or another checked replay interface. The replay @@ -757,20 +769,21 @@ reproduce. The fixed and random campaigns must use the same property, generators, observation recorder, refinement check, and run budget. Only their seed source -may differ. This keeps a random failure eligible for promotion into a pinned -example or fixed campaign. +may differ. This keeps a random failure eligible for promotion into a fixed +history or the fixed-seed campaign. [`fifo-retry-oracle.property.test.ts`](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts) -shows this shape. Its fixed run preserves one scheduler campaign. Its second -campaign uses a random seed by default. To replay its recorded failure, set -`TANSTACK_DB_ORACLE_PROPERTY=offline.fifo-retry` with -`TANSTACK_DB_ORACLE_SEED` and `TANSTACK_DB_ORACLE_PATH`. The fixed run stands -aside, and the random campaign runs the recorded path once. The offline -owners' other properties use the same variables and name their own properties, -such as `offline.leadership.stale-read` and `offline.settlement.retry-decision`. +shows this shape. Its fixed-seed campaign preserves one scheduler campaign. Its +second campaign uses a random campaign seed by default. To replay its recorded +failure, set `TANSTACK_DB_ORACLE_PROPERTY=offline.fifo-retry` with +`TANSTACK_DB_ORACLE_SEED` and `TANSTACK_DB_ORACLE_PATH`. The fixed-seed campaign +stands aside, and the random campaign runs the recorded path once. The offline +oracle owners' other properties use the same variables and name their own +properties, such as `offline.leadership.stale-read` and +`offline.settlement.retry-decision`. Do not use a larger random run count as a substitute for structural reach. -Pinned examples force rare boundaries. Bounded enumeration proves small finite +Fixed histories force rare boundaries. Bounded enumeration proves small finite domains. A stress job increases sampling depth. Record each form separately. ### Make the comparison prove its usefulness @@ -874,22 +887,30 @@ test releases write delivery and lets production apply it test records the visible state at the promised applied checkpoint ``` -The fixture must really hold write delivery. If it delivers the writes and only delays returning from its load function, it controls a different event. Whether the returned Promise settles before or after application is a separate contract question. Record publications during the held interval if the promise concerns what users can see then. A scheduler is helpful only within the boundaries it controls; a seed cannot schedule arbitrary external I/O. The [fast-check scheduler documentation](https://fast-check.dev/docs/advanced/race-conditions/) explains the distinction between an operation's lifecycle and delivery of its result. +The fixture must really hold write delivery. If it delivers the writes and only +delays returning from its load function, it controls a different event. Whether +the returned Promise settles before or after application is a separate contract +question. Record publications during the held interval if the promise concerns +what users can see then. A scheduler is helpful only within the boundaries it +controls; a campaign seed cannot schedule arbitrary external I/O. The +[fast-check scheduler documentation](https://fast-check.dev/docs/advanced/race-conditions/) +explains the distinction between an operation's lifecycle and delivery of its +result. -### Treat driver timing helpers as premises +### Treat production driver timing helpers as premises A production driver often needs helpers such as "wait until the request is blocked" or "let the message reach the worker". Each helper establishes a premise. If a helper does less than its name says, every production driver that uses it can go green without reaching the history. -In one [recorded case](oracle-reviews/pr-2213-opfs-close.md), a driver loop -shared by the Node and Chromium production drivers gave back a lock in the same -turn in which it submitted close. The close message was still crossing a task -boundary, so the earlier request finished first. Close never ran during the -wait, and both worker mutants passed both production drivers. The loop had a -premise check for "a request waits for the lock", but it had none for "close -arrives during that wait". +In one [recorded case](oracle-reviews/pr-2213-opfs-close.md), a production +driver loop shared by the Node and Chromium production drivers gave back a lock +in the same turn in which it submitted close. The close message was still +crossing a task boundary, so the earlier request finished first. Close never ran +during the wait, and both worker mutants passed both production drivers. The +loop had a premise check for "a request waits for the lock", but it had none for +"close arrives during that wait". For each timing helper, do one of these: @@ -953,19 +974,19 @@ restate the expected results. The OPFS request-order oracle shows the layout. [`opfs-request-order-oracle.ts`](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts) -owns the law, the model, the fixed examples, the driver loop, and the -comparison. The +owns the law, the model, the fixed histories, the production driver loop, and +the comparison. The [Node driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.test.ts) and the [Chromium page driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order.opfs.ts) supply the entry point, access-handle control, database names, and result reporting. The Node driver also generates random histories from the same -grammar. Neither driver states expected results, and both feed the same -comparison. The +grammar. Neither production driver states expected results, and both feed the +same comparison. The [Chromium spec](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts) checks for page errors, a completed run, one result per example, and no -violations. It states no expected results either. A witness -that restates expected results needs the record that ORC-014 describes. +violations. It states no expected results either. A witness that restates +expected results needs the record that ORC-014 describes. ## Observe what the contract promises @@ -1054,7 +1075,10 @@ Original trace; reduced trace; reproduction result Primary failure; separate cleanup diagnostics ``` -Check replay commands against the installed tool. A seed is not a recording of the network, operating system or every Promise. If a challenge influenced the design, keep it as a regression or tuning case; it is no longer a held-out test of that design. +Check replay commands against the installed tool. A campaign seed is not a +recording of the network, operating system or every Promise. If a challenge +influenced the design, keep it as a regression or tuning case; it is no longer a +held-out test of that design. ## Review and maintain the portfolio @@ -1141,8 +1165,8 @@ or a claimed repair, ask: Adapter and lifecycle oracles should consider these laws when the contract has the corresponding boundary. They are prompts, not universal requirements. State -why an inapplicable law does not belong to the owner instead of adding a vacuous -case. +why an inapplicable law does not belong to the oracle owner instead of adding a +vacuous case. - **Real-provider conformance:** freeze representative values from each supported provider version. Prove the fixture accepts those values before it @@ -1168,7 +1192,7 @@ case. result and a deterministic work/cardinality measure. Correct rows alone do not establish the work law. -Every owner should also state its known omissions beside the contract. The +Every oracle owner should also state its known limits beside the contract. The coverage map tracks open reusable laws; an unchecked item is not evidence that the neighboring laws are absent. diff --git a/eslint.config.js b/eslint.config.js index c1ad9142cd..1145ef5a91 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,6 @@ import { tanstackConfig } from '@tanstack/eslint-config' -import oraclePlugin from './scripts/eslint-plugin-oracle/index.js' +import oraclePlugin from '@tanstack/eslint-plugin-oracles' +import { oracleLintSettings } from './scripts/oracle-lint-settings.mjs' export default [ ...tanstackConfig, @@ -11,6 +12,9 @@ export default [ `**/.nitro/**`, `**/traildepot/**`, `examples/angular/**`, + // The gap report CLI's evidence store and workspace bundles. + `**/.oracle-gaps/**`, + `**/.oracle-gaps-*`, `packages/db-collection-e2e/vite.config.ts`, // Third-party adapter source vendored unchanged as a conformance fixture. `packages/db-adapter-conformance/tests/neon/vendor/**`, @@ -49,5 +53,9 @@ export default [ 'import/no-cycle': `error`, }, }, + // The oracle rules come from @tanstack/eslint-plugin-oracles; the settings + // give it TanStack DB's environment prefix and framework modules. + { settings: oracleLintSettings }, oraclePlugin.configs.recommended, + oraclePlugin.configs.production, ] diff --git a/package.json b/package.json index d1669ff7c7..0d8fa90749 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "lint:changed": "node scripts/agent-lint-feedback.mjs", "lint:oracles": "node scripts/agent-lint-feedback.mjs --all", "lint:run-budgets": "node scripts/check-oracle-run-counts.mjs", - "test:lint-rules": "vitest run --config scripts/eslint-plugin-oracle/vitest.config.mjs && vitest run --config scripts/oracle-scripts.vitest.config.mjs", + "gap-report": "oracle-gaps --env-prefix TANSTACK_DB_ORACLE", + "test:lint-rules": "vitest run --config scripts/oracle-scripts.vitest.config.mjs", "lint-all": "eslint . --fix", "prepare": "husky", "test": "pnpm --filter \"./packages/**\" test && pnpm --filter @tanstack/db test:indexed-db:package", @@ -51,6 +52,8 @@ "@manypkg/get-packages": "1.1.3", "@svitejs/changesets-changelog-github-compact": "^1.2.0", "@tanstack/eslint-config": "0.3.4", + "@tanstack/eslint-plugin-oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@98a9610", + "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610", "@tanstack/typedoc-config": "0.3.5", "@tanstack/vite-config": "0.5.2", "@testing-library/jest-dom": "^6.9.1", @@ -65,7 +68,7 @@ "eslint": "^9.39.5", "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-react": "^7.37.5", - "fast-check": "^3.23.2", + "fast-check": "^4.10.2", "husky": "^9.1.7", "jsdom": "^27.4.0", "knip": "^5.88.1", diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 958697faa6..5a2d83c290 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -324,7 +324,7 @@ const angularDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-oracle.opfs.spec.ts index 1fa30afbf0..96511fd272 100644 --- a/packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-oracle.opfs.spec.ts @@ -12,6 +12,7 @@ import { makePgClient } from '../../db-collection-e2e/support/global-setup' import { sourceHeadViolation } from '../tests/electric-convergence-oracle' import { createOracleReach } from '../../db/tests/oracle-reach' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import type { Page } from '@playwright/test' import type {} from './electric-source-prefix.opfs' @@ -88,11 +89,16 @@ const observe = (page: Page) => const rowIds = async (page: Page) => (await observe(page)).rows.map(({ id }) => id) -declareOracle({ check: [sourceHeadViolation], grammar: [cells] }) +declareOracle({ + owns: [`src/browser-persistence.ts`], + check: [sourceHeadViolation], + grammar: [cells], +}) for (const cell of cells) { test(`recovers ${cell.present ? `lost` : `resurrected`} rows after ${cell.advance} advancement${cell.drain ? ` with peer drain` : ``}${cell.legacy ? ` from legacy progress` : ``}${cell.callerCursor !== `none` ? ` with explicit ${cell.callerCursor}` : ``}${cell.interrupted ? ` after interrupted recovery` : ``}`, async ({ context, }) => { + oracleRoute(`case`) const reach = createOracleReach( `browser.electric-source-prefix.opfs`, [ diff --git a/packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts b/packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts index 3a346ed0d3..fb75c71b49 100644 --- a/packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts +++ b/packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts @@ -53,6 +53,7 @@ import { fileURLToPath } from 'node:url' import { chromium } from '@playwright/test' import { afterAll, beforeAll, expect, it } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { buildForProduction, consumerVite } from './packaged-consumer' import type { Browser } from '@playwright/test' import type { BrowserWASQLiteDatabase } from '../src' @@ -218,6 +219,7 @@ const promisedOutcomes: Record> = { ], } +// Owns nothing: this file checks the installed package layout (build outputs), not a src function. declareOracle({ check: [expectLayoutOutcome], model: [expectedOutcome], @@ -250,6 +252,7 @@ afterAll(async () => { it.each(layoutCases)( `vite $vite $server $layout install (cache $cache, excluded by $exclusion) matches the layout model`, async (layoutCase) => { + oracleRoute(`case`) const temporaryDirectory = realpathSync( mkdtempSync(join(realpathSync(tmpdir()), `db-wasm-layout-`)), ) @@ -270,6 +273,7 @@ it.each(layoutCases)( // trees. for (const vite of [7, 8] as const) { it(`covers every promised layout outcome under Vite ${vite}`, () => { + oracleRoute(`case`) expect([...coveredOutcomes[vite]].sort()).toEqual( promisedOutcomes[vite].map((outcome) => JSON.stringify(outcome)).sort(), ) diff --git a/packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts index 88db214727..15130734c2 100644 --- a/packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts @@ -21,6 +21,7 @@ */ import { expect, test } from '@playwright/test' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config' import { observe, openProbe } from './leader-close-probe' import type { BrowserContext, Page } from '@playwright/test' @@ -343,22 +344,38 @@ async function runHistory( expect(cleanupFailures).toEqual([]) } -declareOracle({ check: [runHistory], model: [expectedHistory] }) +declareOracle({ + owns: [`src/browser-coordinator.ts`], + check: [runHistory], + model: [expectedHistory], +}) test(`surviving OPFS source Collection accepts work after an idle leader tab closes`, async ({ context, -}) => runHistory(context, `none`)) +}) => { + oracleRoute(`fixed history`) + return runHistory(context, `none`) +}) for (const schedule of [`before`, `after`] as const) { test(`surviving OPFS source Collection reconciles a leader close ${schedule} durable application`, async ({ context, - }) => runHistory(context, schedule)) + }) => { + oracleRoute(`case`) + return runHistory(context, schedule) + }) } test(`surviving OPFS source Collection retains a peer write during exact-ID reconciliation`, async ({ context, -}) => runHistory(context, `after-peer`)) +}) => { + oracleRoute(`fixed history`) + return runHistory(context, `after-peer`) +}) test(`surviving OPFS peers do not republish a commit after its answer is lost`, async ({ context, -}) => runHistory(context, `after-notify`)) +}) => { + oracleRoute(`fixed history`) + return runHistory(context, `after-notify`) +}) diff --git a/packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-oracle.e2e.test.ts b/packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-oracle.e2e.test.ts index 46cb06dd7b..c876ae4dd7 100644 --- a/packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-oracle.e2e.test.ts +++ b/packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-oracle.e2e.test.ts @@ -29,9 +29,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { describeHistory, expectOpenOutcome, + expectedOpenOutcome, openHistories, reachedOutcomes, } from '../tests/opfs-open-outcome-oracle' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { consumerVite, createPackagedConsumer, @@ -407,6 +410,13 @@ async function runOpenHistory(history: OpenHistory): Promise { } } +declareOracle({ + owns: [`src/opfs-database.ts#openBrowserWASQLiteOPFSDatabase`], + check: [expectOpenOutcome], + model: [expectedOpenOutcome], + grammar: [openHistories], +}) + // One block per consumer Vite major. Each runs its histories against a dev // server of that major. function describeOpenOutcomes( @@ -426,12 +436,14 @@ function describeOpenOutcomes( }) it(`reaches every outcome that this driver controls`, () => { + oracleRoute(`case`) expect(reachedOutcomes(histories)).toEqual([...reached]) }) it.each(histories.map((history) => [describeHistory(history), history]))( `%s`, async (_name, history) => { + oracleRoute(`case`) expectOpenOutcome(history, await runOpenHistory(history)) }, 120_000, diff --git a/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts index cee4029f25..428df4bcb6 100644 --- a/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts @@ -7,6 +7,7 @@ import { expect, test } from '@playwright/test' import { requestOrderExamples } from '../tests/opfs-request-order-oracle' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config' import type { OPFSRequestOrderResult } from './opfs-request-order.opfs' // The page's comparison of each fixed history's settlements with the model: no history may report a violation. @@ -21,11 +22,15 @@ function expectRequestOrderLawsHold(result: { ).toEqual([]) } -declareOracle({ check: [expectRequestOrderLawsHold] }) +declareOracle({ + owns: [`src/opfs-database.ts#openBrowserWASQLiteOPFSDatabase`], + check: [expectRequestOrderLawsHold], +}) test(`real Chromium OPFS connection answers requests in order and closes after them`, async ({ page, }) => { + oracleRoute(`fixed history`) test.setTimeout(120_000) const pageErrors: Array = [] page.on(`pageerror`, (error) => pageErrors.push(error.message)) diff --git a/packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts index 3a624d004b..29287a0aaf 100644 --- a/packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts @@ -7,6 +7,7 @@ */ import { expect, test } from '@playwright/test' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config' import type { ReloadClassResult } from './reload-class.opfs' // The page's comparison of each fixed reload history with the oracle: every @@ -23,11 +24,15 @@ function expectReloadClassLawsHold( ).toEqual([]) } -declareOracle({ check: [expectReloadClassLawsHold] }) +declareOracle({ + owns: [`src/browser-coordinator.ts`, `src/browser-persistence.ts`], + check: [expectReloadClassLawsHold], +}) test(`real Chromium OPFS reload is free of avoidable work ahead of its reads`, async ({ page, }) => { + oracleRoute(`fixed history`) test.setTimeout(300_000) const pageErrors: Array = [] page.on(`pageerror`, (error) => pageErrors.push(error.message)) diff --git a/packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-oracle.opfs.spec.ts index 76feef0151..6c24fb0c97 100644 --- a/packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-oracle.opfs.spec.ts @@ -12,6 +12,7 @@ */ import { expect, test } from '@playwright/test' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config' import type { Page } from '@playwright/test' import type { OPFSOracleResult } from './shared-driver-fairness.opfs' @@ -66,22 +67,25 @@ function expectLawsHold(result: OPFSOracleResult): void { expect(result.opfsCleanupFailures).toEqual([]) } -declareOracle({ check: [expectLawsHold] }) +declareOracle({ owns: [`src/wa-sqlite-driver.ts`], check: [expectLawsHold] }) test(`real Chromium OPFS fixture reaches and cleans up cold hydration`, async ({ page, }) => { + oracleRoute(`fixed history`) expectLawsHold(await readOracleResult(page, `neutral`)) }) test(`real Chromium OPFS fixture bounds cold hydration and completes persists in FIFO order`, async ({ page, }) => { + oracleRoute(`fixed history`) expectLawsHold(await readOracleResult(page, `storm`)) }) test(`real Chromium OPFS fixture bounds hydrates queued ahead of later persists`, async ({ page, }) => { + oracleRoute(`fixed history`) expectLawsHold(await readOracleResult(page, `interleaved`)) }) diff --git a/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts index 70dffd204d..b0121a8aed 100644 --- a/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/browser-coordinator-oracle.test.ts @@ -54,6 +54,11 @@ import fc from 'fast-check' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + oracleFixedSeed, + oracleRoute, + oracleRouteOf, +} from '../../db/tests/oracle-config' import { bindTransportControls, cleanupGlobals, @@ -123,6 +128,11 @@ describe(`BrowserCollectionCoordinator`, () => { vi.useFakeTimers() let run = 0 try { + const parameters = oracleFixedSeed(1868, { + numRuns: 12, + endOnFailure: true, + }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -208,7 +218,7 @@ describe(`BrowserCollectionCoordinator`, () => { } }, ), - { seed: 1868, numRuns: 12, endOnFailure: true }, + parameters, ) } finally { vi.useRealTimers() @@ -219,6 +229,11 @@ describe(`BrowserCollectionCoordinator`, () => { describe(`RPC - ensurePersistedIndex`, () => { it(`routes generated collection and RPC histories through their owning adapter`, async () => { let run = 0 + const parameters = oracleFixedSeed(1868, { + numRuns: 18, + endOnFailure: true, + }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.integer({ min: 2, max: 4 }).chain((collectionCount) => @@ -324,7 +339,7 @@ describe(`BrowserCollectionCoordinator`, () => { } }, ), - { seed: 1868, numRuns: 18, endOnFailure: true }, + parameters, ) }) }) @@ -334,6 +349,11 @@ describe(`BrowserCollectionCoordinator`, () => { vi.useFakeTimers() let run = 0 try { + const campaignParameters = oracleFixedSeed(1868, { + numRuns: 18, + endOnFailure: true, + }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -438,7 +458,7 @@ describe(`BrowserCollectionCoordinator`, () => { } }, ), - { seed: 1868, numRuns: 18, endOnFailure: true }, + campaignParameters, ) } finally { vi.useRealTimers() @@ -449,6 +469,11 @@ describe(`BrowserCollectionCoordinator`, () => { describe(`collection and retry-result retention`, () => { it(`releases generated collection-owned state after the last subscriber`, async () => { let run = 0 + const campaignParameters = oracleFixedSeed(1868, { + numRuns: 12, + endOnFailure: true, + }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.integer({ min: 1, max: 4 }).chain((collectionCount) => @@ -522,7 +547,7 @@ describe(`BrowserCollectionCoordinator`, () => { } }, ), - { seed: 1868, numRuns: 12, endOnFailure: true }, + campaignParameters, ) }) @@ -530,6 +555,11 @@ describe(`BrowserCollectionCoordinator`, () => { vi.useFakeTimers() let run = 0 try { + const runParameters = oracleFixedSeed(1868, { + numRuns: 12, + endOnFailure: true, + }) + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -593,7 +623,7 @@ describe(`BrowserCollectionCoordinator`, () => { } }, ), - { seed: 1868, numRuns: 12, endOnFailure: true }, + runParameters, ) } finally { vi.useRealTimers() diff --git a/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts b/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts index 8990effe0a..6caef19b49 100644 --- a/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts @@ -885,7 +885,7 @@ describe(`BrowserCollectionCoordinator`, () => { * * Checkpoints: before the request (premise: no term while the read is * held), after bounded turns with the read still held (term and route), and - * after release (settlement and RPC count). Hostile mutants that skip any + * after release (settlement and RPC count). Mutants that skip any * need mark fail the held-read checkpoint or the settlement deadline. * * Per-election cells: a follower takes over from a leader that served an @@ -3375,7 +3375,7 @@ describe(`BrowserCollectionCoordinator`, () => { } } - // Reach witnesses above distinguish an RPC crossover from ordinary + // Reach checks above distinguish an RPC crossover from ordinary // leadership transfer. The exact-ID answer must settle the wrapper's // first receipt without cleanup or a second sync run. const actual = { diff --git a/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts b/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts index a0498e34b8..5d0d04697a 100644 --- a/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts @@ -55,4 +55,6 @@ export function sourceHeadViolation( return undefined } +// Owns nothing: this module holds the model and check only. The production +// path is driven by the multi-tab Electric test (multi-tab-electric-oracle.test.ts), which declares the owned entry. declareOracle({ check: [sourceHeadViolation] }) diff --git a/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts index 35d159398e..19ed765820 100644 --- a/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts @@ -30,7 +30,7 @@ * open tab catches up, the durable rows and every open tab show the server * head, however earlier lagging commits left them. When a lagging tab * commits an older row and then closes for good, nothing redelivers the head - * to the faster tab, so this law fails today. That history is pinned with + * to the faster tab, so this law fails today. That history is a fixed history run with * `it.fails` until #2209 orders source writes by server position; generated * histories never close a tab for good. * @@ -79,7 +79,7 @@ * * The generated histories below judge Laws 1, 3, 4, and 5 over interleaved * server changes, deploys, deliveries, stops, and recoveries, against a - * model of the durable rows. One known gap is left out of them and pinned: + * model of the durable rows. One known limit is left out of them and kept as a fixed history: * a resumed tab that lags behind a peer can receive an update for a row the * peer already deleted, and Electric's resume check then puts it in `error`. * @@ -101,9 +101,12 @@ import { createCollection } from '@tanstack/db' import { createOracleReach } from '../../db/tests/oracle-reach.js' import { declareOracle } from '../../db/tests/oracle-declaration' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' @@ -695,6 +698,7 @@ describe(`multi-tab Electric over one persisted database`, () => { stop, recovery, }: (typeof stormHistories)[number]) => { + oracleRoute(`case`) host = openHost() const tabs = await openWarmTabs(host, tabCount, syncMode) for (const tab of tabs) { @@ -925,7 +929,7 @@ describe(`multi-tab Electric over one persisted database`, () => { | { kind: `advance`; tab: number; pick: number } | { kind: `catch up`; tab: number; pick: number } // A tab that closes for good. Generated histories never close a tab; - // only the pinned #2209 replay does (see below). + // only the fixed #2209 regression does (see below). | { kind: `close`; tab: number } | { kind: `stop`; tab: number; pick: number } | { @@ -997,7 +1001,7 @@ describe(`multi-tab Electric over one persisted database`, () => { // reset/stop suffix of 4 + 2*tabCount steps (8 or 10), or a lagging-commit // suffix of 6 + 3*tabCount steps (12 or 15). Removing // the open delivery, stop, or peer commit loses the law's premise; recovering - // before the peer commit would test a different boundary. Existing pinned + // before the peer commit would test a different boundary. Existing fixed // histories still use the same driver without this prefix. const generatedHistory = fc .record({ @@ -1166,7 +1170,7 @@ describe(`multi-tab Electric over one persisted database`, () => { const runGeneratedHistory = async ( { tabCount, steps }: GeneratedHistory, - onKnownGap: () => void, + onKnownLimit: () => void, onRecovery?: ( divergentBeforeRecovery: boolean, how: `restart` | `reload`, @@ -1302,9 +1306,9 @@ describe(`multi-tab Electric over one persisted database`, () => { // Electric's updates carry only changed columns (the shape's default // replica), so an update cannot create a row the tab does not hold, // and Electric drops it. The tab holds the durable rows, or, inside a - // refetch, the snapshot it has received. Known gap: a resumed tab + // refetch, the snapshot it has received. Known limit: a resumed tab // instead reports an unseen row and fails (issue #2267). Generated - // campaigns leave that history out; the pinned replay below keeps it. + // campaigns leave that history out; the fixed regression below keeps it. const held = new Set(model.truncates ? [] : durable.keys()) const track = (message: Message) => { if (!(`key` in message)) return @@ -1319,7 +1323,7 @@ describe(`multi-tab Electric over one persisted database`, () => { message.headers.operation === `update` && !held.has(message.value.id) ) { - if (model.resumed && !model.truncates) onKnownGap() + if (model.resumed && !model.truncates) onKnownLimit() return false } track(message) @@ -1725,6 +1729,7 @@ describe(`multi-tab Electric over one persisted database`, () => { `interrupted reset before explicit now recovery`, ] as const const reach = createOracleReach(`browser.electric-source-prefix`, states) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(sourcePrefixHistory, async (history) => { const slowTab = 1 - history.fastTab @@ -1817,15 +1822,20 @@ describe(`multi-tab Electric over one persisted database`, () => { } it.skipIf(oracleReplayRequested())( `converges after interleaved source prefixes (fixed seed)`, - () => runSourcePrefixCampaign({ numRuns: oracleRuns(80), seed: 2281 }), + () => { + return runSourcePrefixCampaign( + oracleFixedSeed(2281, { numRuns: oracleRuns(80) }), + ) + }, 600_000, ) oracleReplayTest(it, `browser.electric-source-prefix`)( `converges after interleaved source prefixes (random or replayed)`, - () => - runSourcePrefixCampaign( + () => { + return runSourcePrefixCampaign( oraclePropertyOptions(80, `browser.electric-source-prefix`), - ), + ) + }, 600_000, ) @@ -1849,6 +1859,7 @@ describe(`multi-tab Electric over one persisted database`, () => { // generated driver folds committed batches into its own model of durable // rows. The storm cells and the generated-history arbitrary are the grammar. declareOracle({ + owns: [`src/browser-coordinator.ts`, `src/browser-persistence.ts`], check: [runStormHistory, runGeneratedHistory], grammar: [stormHistories, generatedHistory, sourcePrefixHistory], }) @@ -1877,11 +1888,12 @@ describe(`multi-tab Electric over one persisted database`, () => { parameters: ReturnType, ) => { generatedReach = createGeneratedReach() + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(generatedHistory, async (history) => { let primary: { error: unknown } | undefined try { - // fc.pre discards a history that reaches the known gap. + // fc.pre discards a history that reaches the known limit. await runGeneratedHistory(history, () => fc.pre(false)) } catch (error) { primary = { error } @@ -1899,31 +1911,35 @@ describe(`multi-tab Electric over one persisted database`, () => { } it.skipIf(oracleReplayRequested())( `keeps reserved commits lawful across generated multi-tab histories (fixed seed)`, - () => - runGeneratedCampaign({ - numRuns: oracleRuns(GENERATED_RUNS), - seed: GENERATED_FIXED_SEED, - }), + () => { + return runGeneratedCampaign( + oracleFixedSeed(GENERATED_FIXED_SEED, { + numRuns: oracleRuns(GENERATED_RUNS), + }), + ) + }, 600_000, ) oracleReplayTest(it, GENERATED_PROPERTY)( `keeps reserved commits lawful across generated multi-tab histories (random or replayed)`, - () => - runGeneratedCampaign( + () => { + return runGeneratedCampaign( oraclePropertyOptions(GENERATED_RUNS, GENERATED_PROPERTY), - ), + ) + }, 600_000, ) - // Known gap #2267, pinned: the shrunk generated history in which a resumed tab + // Known limit #2267, as a fixed history: the shrunk generated history in which a resumed tab // that lags behind its peer receives an update for a row the peer already - // deleted. It replays through the same driver and checks with the known-gap + // deleted. It replays through the same driver and checks with the known-limit // detector off. Remove `fails` when Electric's resume check stops treating // rows from peers' commits as this stream's evidence. it.fails( `keeps a resumed lagging tab out of error when a peer deleted the row it updates`, - () => - runGeneratedHistory( + () => { + oracleRoute(`calibration`) + return runGeneratedHistory( { tabCount: 2, steps: [ @@ -1936,93 +1952,88 @@ describe(`multi-tab Electric over one persisted database`, () => { ], }, () => undefined, - ), + ) + }, 60_000, ) // The final delivery can be the first attempt to commit a transaction // abandoned by the reset. The driver must observe its failure, recover, // and only then judge convergence. This replays the original issue trace. - it( - `recovers a reset-abandoned source that fails during final catch-up`, - () => - runGeneratedHistory( - { - tabCount: 2, - steps: [ - { kind: `server change`, key: 1, pick: 0 }, - { kind: `advance`, tab: 1, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `advance`, tab: 0, pick: 0 }, - { kind: `peer reset` }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `deploy` }, - { kind: `deploy` }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - ], - }, - () => undefined, - ), - 60_000, - ) + it(`recovers a reset-abandoned source that fails during final catch-up`, () => { + oracleRoute(`fixed history`) + return runGeneratedHistory( + { + tabCount: 2, + steps: [ + { kind: `server change`, key: 1, pick: 0 }, + { kind: `advance`, tab: 1, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `advance`, tab: 0, pick: 0 }, + { kind: `peer reset` }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `deploy` }, + { kind: `deploy` }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + ], + }, + () => undefined, + ) + }, 60_000) // The later no-deploy reset trace reaches the same final-delivery failure // from a reloaded tab. Recovery must happen after that failed commit too. // Seed -1371495859, path 75:0:0:0:0:0:0:1:1:0:0. - it( - `recovers a reloaded source whose final commit was abandoned by a peer reset`, - () => - runGeneratedHistory( - { - tabCount: 2, - steps: [ - { kind: `recover`, tab: 0, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `advance`, tab: 0, pick: 0 }, - { kind: `advance`, tab: 1, pick: 0 }, - { kind: `advance`, tab: 0, pick: 0 }, - { kind: `peer reset` }, - { kind: `advance`, tab: 0, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - ], - }, - () => undefined, - ), - 60_000, - ) + it(`recovers a reloaded source whose final commit was abandoned by a peer reset`, () => { + oracleRoute(`fixed history`) + return runGeneratedHistory( + { + tabCount: 2, + steps: [ + { kind: `recover`, tab: 0, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `advance`, tab: 0, pick: 0 }, + { kind: `advance`, tab: 1, pick: 0 }, + { kind: `advance`, tab: 0, pick: 0 }, + { kind: `peer reset` }, + { kind: `advance`, tab: 0, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + ], + }, + () => undefined, + ) + }, 60_000) // A discontinuous source prefix does not corrupt the cached key set. Keep // durable rows visible during reload while a fresh source snapshot repairs // continuation. This is a reduced random history, replayed by the same law. - it( - `keeps cached rows visible while recovering a discontinuous source`, - () => - runGeneratedHistory( - { - tabCount: 2, - steps: [ - { kind: `server change`, key: 1, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `catch up`, tab: 1, pick: 1 }, - { kind: `catch up`, tab: 0, pick: 0 }, - { kind: `recover`, tab: 0, pick: 0 }, - { kind: `recover`, tab: 0, pick: 0 }, - { kind: `stop`, tab: 0, pick: 0 }, - { kind: `peer reset` }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `catch up`, tab: 0, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `advance`, tab: 0, pick: 0 }, - ], - }, - () => undefined, - ), - 60_000, - ) + it(`keeps cached rows visible while recovering a discontinuous source`, () => { + oracleRoute(`fixed history`) + return runGeneratedHistory( + { + tabCount: 2, + steps: [ + { kind: `server change`, key: 1, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `catch up`, tab: 1, pick: 1 }, + { kind: `catch up`, tab: 0, pick: 0 }, + { kind: `recover`, tab: 0, pick: 0 }, + { kind: `recover`, tab: 0, pick: 0 }, + { kind: `stop`, tab: 0, pick: 0 }, + { kind: `peer reset` }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `catch up`, tab: 0, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `advance`, tab: 0, pick: 0 }, + ], + }, + () => undefined, + ) + }, 60_000) - // Known gap #2209, pinned: tab A commits v1 and v2; tab B's lagging stream + // Known limit #2209, as a fixed history: tab A commits v1 and v2; tab B's lagging stream // then commits only v1, so the durable row falls back to v1, and B closes // for good. Nothing redelivers the head to A, so Law 2's final check fails. // Generated histories never close a tab for good, which leaves this @@ -2030,8 +2041,9 @@ describe(`multi-tab Electric over one persisted database`, () => { // Remove `fails` when source writes are ordered by server position. it.fails( `converges an open tab to the head after a lagging tab commits an older row and closes`, - () => - runGeneratedHistory( + () => { + oracleRoute(`calibration`) + return runGeneratedHistory( { tabCount: 2, steps: [ @@ -2044,7 +2056,8 @@ describe(`multi-tab Electric over one persisted database`, () => { ], }, () => undefined, - ), + ) + }, 60_000, ) @@ -2053,30 +2066,28 @@ describe(`multi-tab Electric over one persisted database`, () => { // tabs catch up, but after tab B stops, tab A catches up, and tab A stops, // the durable rows lose row 1. Law 2 expects every open tab and the durable // rows to show the head. The source-progress repair forces a fresh snapshot. - // It replays through the same driver and checks with the known-gap detector + // It replays through the same driver and checks with the known-limit detector // off. The generated source-prefix campaign extends this same law. - it( - `keeps a durable row after both tabs catch up to it and then stop`, - () => - runGeneratedHistory( - { - tabCount: 2, - steps: [ - { kind: `server change`, key: 1, pick: 0 }, - { kind: `catch up`, tab: 0, pick: 0 }, - { kind: `server change`, key: 1, pick: 0 }, - { kind: `catch up`, tab: 0, pick: 0 }, - { kind: `catch up`, tab: 1, pick: 1 }, - { kind: `catch up`, tab: 0, pick: 0 }, - { kind: `stop`, tab: 1, pick: 0 }, - { kind: `catch up`, tab: 0, pick: 0 }, - { kind: `stop`, tab: 0, pick: 0 }, - ], - }, - () => undefined, - ), - 60_000, - ) + it(`keeps a durable row after both tabs catch up to it and then stop`, () => { + oracleRoute(`fixed history`) + return runGeneratedHistory( + { + tabCount: 2, + steps: [ + { kind: `server change`, key: 1, pick: 0 }, + { kind: `catch up`, tab: 0, pick: 0 }, + { kind: `server change`, key: 1, pick: 0 }, + { kind: `catch up`, tab: 0, pick: 0 }, + { kind: `catch up`, tab: 1, pick: 1 }, + { kind: `catch up`, tab: 0, pick: 0 }, + { kind: `stop`, tab: 1, pick: 0 }, + { kind: `catch up`, tab: 0, pick: 0 }, + { kind: `stop`, tab: 0, pick: 0 }, + ], + }, + () => undefined, + ) + }, 60_000) // Issue #2281 reduces to the same six-step history for both membership // directions. A stores two server changes; B stores only the first. A's @@ -2099,49 +2110,43 @@ describe(`multi-tab Electric over one persisted database`, () => { const reopenPeer: HistoryStep = { kind: `recover`, tab: 1, pick: 0 } // Keeping only the maximum cursor does not repair these rows. - it( - `${behavior} after a cursor-only commit and a lagging tab reload`, - () => - runGeneratedHistory( - { - tabCount: 2, - steps: [...stalePrefix, publishCursor, reopenPeer], - }, - () => undefined, - ), - 60_000, - ) + it(`${behavior} after a cursor-only commit and a lagging tab reload`, () => { + oracleRoute(`fixed history`) + return runGeneratedHistory( + { + tabCount: 2, + steps: [...stalePrefix, publishCursor, reopenPeer], + }, + () => undefined, + ) + }, 60_000) // A peer that drains its backlog supplies the missing change itself. - it( - `${behavior} when the lagging stream drains before reloading`, - () => - runGeneratedHistory( - { - tabCount: 2, - steps: [ - ...stalePrefix, - publishCursor, - { kind: `catch up`, tab: 1, pick: 0 }, - reopenPeer, - ], - }, - () => undefined, - ), - 60_000, - ) + it(`${behavior} when the lagging stream drains before reloading`, () => { + oracleRoute(`fixed history`) + return runGeneratedHistory( + { + tabCount: 2, + steps: [ + ...stalePrefix, + publishCursor, + { kind: `catch up`, tab: 1, pick: 0 }, + reopenPeer, + ], + }, + () => undefined, + ) + }, 60_000) // Without A's cursor-only commit, B resumes from its older position and // receives the missing change. This distinguishes cursor advancement // from reload or lag alone as the cause of the permanent mismatch. - it( - `${behavior} when the lagging tab reloads before the cursor advances`, - () => - runGeneratedHistory( - { tabCount: 2, steps: [...stalePrefix, reopenPeer] }, - () => undefined, - ), - 60_000, - ) + it(`${behavior} when the lagging tab reloads before the cursor advances`, () => { + oracleRoute(`fixed history`) + return runGeneratedHistory( + { tabCount: 2, steps: [...stalePrefix, reopenPeer] }, + () => undefined, + ) + }, 60_000) } }) diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.test.ts index 3a5f6b3fb9..eecd60941f 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.test.ts @@ -13,10 +13,13 @@ * lookup and the real worker's WASM fetch. */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { oracleRoute } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { openBrowserWASQLiteOPFSDatabase } from '../src/opfs-database' import { describeHistory, expectOpenOutcome, + expectedOpenOutcome, openHistories, reachedOutcomes, } from './opfs-open-outcome-oracle' @@ -31,6 +34,15 @@ import type { BrowserOPFSWorkerResponse, } from '../src/opfs-worker-protocol' +// The helper module declares the law and model; this driver is the only +// production path that its open histories reach. +declareOracle({ + owns: [`src/opfs-database.ts#openBrowserWASQLiteOPFSDatabase`], + check: [expectOpenOutcome], + model: [expectedOpenOutcome], + grammar: [openHistories], +}) + const jsdomReach: OpenDriverReach = { lookup: [`not needed`], workerStart: [`starts`, `throws`, `fails to load`], @@ -210,6 +222,7 @@ async function runOpenHistory(history: OpenHistory): Promise { describe(`OPFS open outcome oracle (jsdom driver)`, () => { it(`reaches every outcome that this driver controls`, () => { + oracleRoute(`case`) expect(reachedOutcomes(histories)).toEqual([ `initialization: AbortError`, `initialization: TimeoutError`, @@ -222,6 +235,7 @@ describe(`OPFS open outcome oracle (jsdom driver)`, () => { it.each(histories.map((history) => [describeHistory(history), history]))( `%s`, async (_name, history) => { + oracleRoute(`case`) expectOpenOutcome(history, await runOpenHistory(history)) }, ) diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.ts b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.ts index 78c00d467b..79d8fab217 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.ts @@ -284,6 +284,8 @@ export function describeHistory(history: OpenHistory): string { // The model predicts each settlement from the history; the check compares the // driver's observation with that prediction. The grammar generates the histories. +// Owns nothing: this module holds the model and check only. The production +// path is driven by opfs-open-outcome-oracle.test.ts, which declares the owned entry. declareOracle({ check: [expectOpenOutcome], model: [expectedOpenOutcome], diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts index 812039b765..6d83af6dc5 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts @@ -1,8 +1,11 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { afterEach, describe, expect, vi } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -43,7 +46,7 @@ import type { BrowserOPFSWorkerErrorCode } from '../src/opfs-worker-protocol' * * The real worker answers earlier requests before close; the request-order * oracle owns that law. This file's controlled worker can answer in any order, - * so the close lane also checks that pending work settles when close settles + * so the close grammar also checks that pending work settles when close settles * first. * * Controlled page and worker objects implement only the browser event and @@ -898,103 +901,160 @@ and termination count. Synthetic events and a fake worker do not prove real bfcache admission, durability, native handle release, or wa-sqlite #88. The Chromium Web Lock fixture checks native queued-lock release separately. Challenge/replay: fixed examples exhaust the cheap structural products. Each -generated lane runs a fixed and an unseeded campaign with the same grammar and +generated property runs a fixed and an unseeded campaign with the same grammar and run budget. Verbose FastCheck output retains original/reduced traces. A path -replay selects only the direct replay of its lane's property, named by +replay selects only the direct replay of its property, named by TANSTACK_DB_ORACLE_PROPERTY: opfs.page-lifecycle, opfs.mixed-execute, opfs.terminal-event, opfs.close, or opfs.failure-cleanup. A seed without a path reseeds the unseeded campaigns. Run it with this file's package-local Vitest command. */ declareOracle({ + owns: [`src/opfs-database.ts#openBrowserWASQLiteOPFSDatabase`], check: [runFailureHistory], model: [expectedCloseSettlements], }) describe(`OPFS page lifecycle oracle`, () => { - oracleReplayTest(fcTest).prop([pagehideHistory], { - seed: 1844, + const pagehideHistoryFixedSeed = oracleFixedSeed(1844, { numRuns: oracleRuns(48), examples: pagehideExamples, verbose: true, - })( + }) + oracleReplayTest(fcTest).prop([pagehideHistory], pagehideHistoryFixedSeed)( `matches the independent model across generated histories (fixed)`, - runPagehideHistory, + (history) => { + oracleRoute(oracleRouteOf(pagehideHistoryFixedSeed)) + return runPagehideHistory(history) + }, ) - oracleReplayTest(fcTest, `opfs.page-lifecycle`).prop([pagehideHistory], { + const pagehideHistoryCampaign = { ...oraclePropertyOptions(48, `opfs.page-lifecycle`), examples: pagehideExamples, verbose: true, - })( + } + oracleReplayTest(fcTest, `opfs.page-lifecycle`).prop( + [pagehideHistory], + pagehideHistoryCampaign, + )( `matches the independent model across generated histories (random)`, - runPagehideHistory, + (history) => { + oracleRoute(oracleRouteOf(pagehideHistoryCampaign)) + return runPagehideHistory(history) + }, ) - oracleReplayTest(fcTest).prop([mixedExecuteHistory], { - seed: 1845, + const mixedExecuteHistoryFixedSeed = oracleFixedSeed(1845, { numRuns: oracleRuns(32), examples: mixedExamples, verbose: true, - })( + }) + oracleReplayTest(fcTest).prop( + [mixedExecuteHistory], + mixedExecuteHistoryFixedSeed, + )( `preserves completed siblings while aborting pending siblings (fixed)`, - runMixedExecuteHistory, + (history) => { + oracleRoute(oracleRouteOf(mixedExecuteHistoryFixedSeed)) + return runMixedExecuteHistory(history) + }, ) - oracleReplayTest(fcTest, `opfs.mixed-execute`).prop([mixedExecuteHistory], { + const mixedExecuteHistoryCampaign = { ...oraclePropertyOptions(32, `opfs.mixed-execute`), examples: mixedExamples, verbose: true, - })( + } + oracleReplayTest(fcTest, `opfs.mixed-execute`).prop( + [mixedExecuteHistory], + mixedExecuteHistoryCampaign, + )( `preserves completed siblings while aborting pending siblings (random)`, - runMixedExecuteHistory, + (history) => { + oracleRoute(oracleRouteOf(mixedExecuteHistoryCampaign)) + return runMixedExecuteHistory(history) + }, ) - oracleReplayTest(fcTest).prop([terminalEventHistory], { - seed: 1847, + const terminalEventHistoryFixedSeed = oracleFixedSeed(1847, { numRuns: oracleRuns(24), examples: terminalEventExamples, verbose: true, - })( + }) + oracleReplayTest(fcTest).prop( + [terminalEventHistory], + terminalEventHistoryFixedSeed, + )( `rejects pending work across Worker terminal event paths (fixed)`, - runTerminalEventHistory, + (history) => { + oracleRoute(oracleRouteOf(terminalEventHistoryFixedSeed)) + return runTerminalEventHistory(history) + }, ) - oracleReplayTest(fcTest, `opfs.terminal-event`).prop([terminalEventHistory], { + const terminalEventHistoryCampaign = { ...oraclePropertyOptions(24, `opfs.terminal-event`), examples: terminalEventExamples, verbose: true, - })( + } + oracleReplayTest(fcTest, `opfs.terminal-event`).prop( + [terminalEventHistory], + terminalEventHistoryCampaign, + )( `rejects pending work across Worker terminal event paths (random)`, - runTerminalEventHistory, + (history) => { + oracleRoute(oracleRouteOf(terminalEventHistoryCampaign)) + return runTerminalEventHistory(history) + }, ) - oracleReplayTest(fcTest).prop([closeHistory], { - seed: 1848, + const closeHistoryFixedSeed = oracleFixedSeed(1848, { numRuns: oracleRuns(32), examples: closeExamples, verbose: true, - })( + }) + oracleReplayTest(fcTest).prop([closeHistory], closeHistoryFixedSeed)( `settles pending work and refuses new work across explicit close (fixed)`, - runCloseHistory, + (history) => { + oracleRoute(oracleRouteOf(closeHistoryFixedSeed)) + return runCloseHistory(history) + }, ) - oracleReplayTest(fcTest, `opfs.close`).prop([closeHistory], { + const closeHistoryCampaign = { ...oraclePropertyOptions(32, `opfs.close`), examples: closeExamples, verbose: true, - })( + } + oracleReplayTest(fcTest, `opfs.close`).prop( + [closeHistory], + closeHistoryCampaign, + )( `settles pending work and refuses new work across explicit close (random)`, - runCloseHistory, + (history) => { + oracleRoute(oracleRouteOf(closeHistoryCampaign)) + return runCloseHistory(history) + }, ) - oracleReplayTest(fcTest).prop([failureHistory], { - seed: 1846, + const failureHistoryFixedSeed = oracleFixedSeed(1846, { numRuns: oracleRuns(18), examples: failureExamples, verbose: true, - })( + }) + oracleReplayTest(fcTest).prop([failureHistory], failureHistoryFixedSeed)( `matches terminal cleanup and error mapping across worker failures (fixed)`, - runFailureHistory, + (history) => { + oracleRoute(oracleRouteOf(failureHistoryFixedSeed)) + return runFailureHistory(history) + }, ) - oracleReplayTest(fcTest, `opfs.failure-cleanup`).prop([failureHistory], { + const failureHistoryCampaign = { ...oraclePropertyOptions(18, `opfs.failure-cleanup`), examples: failureExamples, verbose: true, - })( + } + oracleReplayTest(fcTest, `opfs.failure-cleanup`).prop( + [failureHistory], + failureHistoryCampaign, + )( `matches terminal cleanup and error mapping across worker failures (random)`, - runFailureHistory, + (history) => { + oracleRoute(oracleRouteOf(failureHistoryCampaign)) + return runFailureHistory(history) + }, ) }) diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts index 6300b9ed04..86694a382f 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle.test.ts @@ -1,7 +1,7 @@ /** * Scenario checks for the OPFS page lifecycle: open deadlines, abort during open, * and abort during option evaluation. `opfs-page-lifecycle-oracle.test.ts` owns - * the lifecycle law and its generated histories; these tests pin single timing + * the lifecycle law and its generated histories; these tests check single timing * scenarios with hand-written expectations. */ import { afterEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.test.ts index a6e5949b6f..f14c5b5feb 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.test.ts @@ -14,10 +14,14 @@ import { createRequire } from 'node:module' import { fc, test as fcTest } from '@fast-check/vitest' import { afterAll, beforeAll, describe, expect, vi } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { openBrowserWASQLiteOPFSDatabase } from '../src/opfs-database' import { findRequestOrderViolation, @@ -269,27 +273,40 @@ const requestOrderHistory = fc const requestOrderProperty = `opfs.request-order` +declareOracle({ + owns: [`src/opfs-database.ts#openBrowserWASQLiteOPFSDatabase`], + check: [runRequestOrderHistory], + grammar: [requestOrderHistory], +}) + describe(`OPFS request order oracle (Node driver)`, () => { // A path replay of this property selects only its random campaign. - oracleReplayTest(fcTest).prop([requestOrderHistory], { - seed: 1849, + const requestOrderHistoryFixedSeed = oracleFixedSeed(1849, { numRuns: oracleRuns(24), examples: requestOrderExamples.map((history): [RequestOrderHistory] => [ history, ]), verbose: true, - })( - `answers requests in order and closes after them (fixed)`, - runRequestOrderHistory, - ) - oracleReplayTest(fcTest, requestOrderProperty).prop([requestOrderHistory], { + }) + oracleReplayTest(fcTest).prop( + [requestOrderHistory], + requestOrderHistoryFixedSeed, + )(`answers requests in order and closes after them (fixed)`, (history) => { + oracleRoute(oracleRouteOf(requestOrderHistoryFixedSeed)) + return runRequestOrderHistory(history) + }) + const requestOrderHistoryCampaign = { ...oraclePropertyOptions(24, requestOrderProperty), examples: requestOrderExamples.map((history): [RequestOrderHistory] => [ history, ]), verbose: true, - })( - `answers requests in order and closes after them (random)`, - runRequestOrderHistory, - ) + } + oracleReplayTest(fcTest, requestOrderProperty).prop( + [requestOrderHistory], + requestOrderHistoryCampaign, + )(`answers requests in order and closes after them (random)`, (history) => { + oracleRoute(oracleRouteOf(requestOrderHistoryCampaign)) + return runRequestOrderHistory(history) + }) }) diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts b/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts index ecf9ba3f79..cd27ae4e30 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-request-order-oracle.ts @@ -229,6 +229,8 @@ export const requestOrderExamples: Array = ( ), ) +// Owns nothing: this module holds the model and check only. The production +// path is driven by opfs-request-order-oracle.test.ts, which declares the owned entry. declareOracle({ check: [findRequestOrderViolation], model: [expectedRequestOrder], 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 0dd9f4bc07..f052487e42 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,9 +1,12 @@ import { fc } from '@fast-check/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -321,7 +324,7 @@ class, and two storage-root examples include WebKit's exact UnknownError. A non-Error cause is outside this promise. Removing either class or the absent case loses one of those fixed witnesses. Challenge/replay: the present-cause examples reject a response that drops the -cause; the absent-cause examples reject an invented cause. Pinned examples run +cause; the absent-cause examples reject an invented cause. Fixed histories 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 @@ -329,19 +332,27 @@ 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. */ -declareOracle({ check: [checkDiagnostic], model: [referenceMessage] }) +declareOracle({ + owns: [`src/opfs-worker.ts`], + check: [checkDiagnostic], + model: [referenceMessage], +}) describe(`OPFS worker diagnostics oracle`, () => { if (!oracleReplayRequested()) { it.each(diagnosticExamples)( - `preserves the pinned open-failure diagnostic (%#)`, - checkDiagnostic, + `preserves the fixed open-failure diagnostic (%#)`, + (input) => { + oracleRoute(`case`) + return checkDiagnostic(input) + }, ) } it.skipIf(oracleReplayRequested())( `rejects a diagnostic that drops an exposed VFS cause`, async () => { + oracleRoute(`calibration`) const input = diagnosticExamples[6]![0] as OpenFailure const response = await initialize(input) const expected = referenceResponse(input).response @@ -351,14 +362,25 @@ describe(`OPFS worker diagnostics oracle`, () => { }, ) - const checkDiagnostics = (options: { numRuns: number; seed?: number }) => - fc.assert(fc.asyncProperty(diagnosticInput, checkDiagnostic), { - ...options, - verbose: true, - }) + // Each campaign registers the route of the parameters it runs with. + const checkDiagnostics = (options: { + numRuns: number + seed?: number + path?: string + }) => { + const parameters = { ...options, verbose: true } + oracleRoute(oracleRouteOf(parameters)) + return fc.assert( + fc.asyncProperty(diagnosticInput, checkDiagnostic), + parameters, + ) + } oracleReplayTest(it)( `matches the independent diagnostic formatter (fixed campaign)`, - () => checkDiagnostics({ seed: 1846, numRuns: oracleRuns(diagnosticRuns) }), + () => + checkDiagnostics( + oracleFixedSeed(1846, { numRuns: oracleRuns(diagnosticRuns) }), + ), 15_000, ) oracleReplayTest(it, diagnosticProperty)( 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 0f7219d939..aedba57bc3 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 @@ -14,6 +14,11 @@ import { createBrowserWASQLitePersistence, persistedCollectionOptions, } from '../src' +import { + oracleFixedSeed, + oracleRoute, + oracleRouteOf, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { createWASQLiteTestDatabase } from './helpers/wa-sqlite-test-db' import { @@ -105,7 +110,7 @@ function coordinatorOracleCampaigns( return [ { mode: `fixed`, - parameters: { numRuns: RUNS, seed: FIXED_SEED }, + parameters: oracleFixedSeed(FIXED_SEED, { numRuns: RUNS }), }, { mode: `random`, parameters: { numRuns: RUNS } }, ] @@ -185,7 +190,7 @@ Reach, challenge, replay, cleanup, and limits: checker rejects an unowned apply. Every production-path test records a reached checkpoint before comparing the independent ledger. - Each generated property uses the same generator, driver, observations, and - run budget in a retained fixed-seed lane and a seedless random lane. Replay + run budget in a retained fixed-seed campaign and a seedless random campaign. Replay registers only the requested property when TANSTACK_DB_COORDINATOR_ORACLE_{PROPERTY,SEED,PATH} are supplied together; RUNS may adjust the shared budget. The thrown report retains the first @@ -320,6 +325,7 @@ async function observeOwnerEntryOrResponse( describe(`per-collection coordinator wire and acknowledgement oracle`, () => { it(`posts the callback-bearing subset witness as clone-safe wire data`, async () => { + oracleRoute(`fixed history`) const decoded: Array = [] const leaderAdapter = createRecordingAdapter({ id: `leader`, @@ -413,6 +419,7 @@ describe(`per-collection coordinator wire and acknowledgement oracle`, () => { }) it(`calibrates the clone checker against safe and function-valued payloads`, () => { + oracleRoute(`calibration`) const safe = { collectionId: `alpha`, where: { type: `eq`, field: `group`, value: `kept` }, @@ -444,6 +451,7 @@ describe(`per-collection coordinator wire and acknowledgement oracle`, () => { }) it(`calibrates acknowledgement checks against entry-only and false-success faults`, () => { + oracleRoute(`calibration`) const expected = subsetSemantics(complexSubsetDemand(false)) const allowed: SubsetAckTrace = { ownerInvocation: `coordinator`, @@ -474,6 +482,7 @@ describe(`per-collection coordinator wire and acknowledgement oracle`, () => { }) it(`acknowledges a follower subset only after the leader source completed it`, async () => { + oracleRoute(`fixed history`) const upstreamEvents: Array<{ phase: `entered` | `completed` semantics: SemanticValue @@ -643,7 +652,7 @@ describe(`per-collection coordinator wire and acknowledgement oracle`, () => { ), acknowledgedAfterResponseDelivery: acknowledged, } - // D13 refinement: one comparator owns both the production verdict and hostile + // D13 refinement: one comparator owns both the production verdict and mutant // entry-only/false-success calibrations above. expect(subsetAckViolations(trace)).toEqual([]) }, [ @@ -916,6 +925,7 @@ function remoteLeaseHistoryViolations( describe(`remote subset ownership lease oracle`, () => { it(`calibrates collection-scoped acquisition identity against a global-id mutant`, () => { + oracleRoute(`calibration`) const expected: CollectionScopedLeaseSnapshot = { alpha: { loads: 1, unloads: 0, active: 1 }, beta: { loads: 1, unloads: 0, active: 1 }, @@ -933,6 +943,7 @@ describe(`remote subset ownership lease oracle`, () => { }) it(`keeps reused option identities independent per collection across bounded histories`, async () => { + oracleRoute(`fixed history`) type Action = { kind: `ensure` | `release` collectionId: `alpha` | `beta` @@ -1074,6 +1085,7 @@ describe(`remote subset ownership lease oracle`, () => { }) it(`retains the outer Collection acquisition across leader takeover and releases both owner acquisition leases exactly`, async () => { + oracleRoute(`fixed history`) type Todo = { id: string; title: string } const firstOwner = new RemoteLeaseOwnerLedger() const secondOwner = new RemoteLeaseOwnerLedger() @@ -1206,27 +1218,28 @@ describe(`remote subset ownership lease oracle`, () => { }) it(`calibrates the lease checker against leak, missing replay, and inexact release mutants`, () => { + oracleRoute(`calibration`) expect( remoteLeaseTakeoverViolations(expectedRemoteLeaseTakeoverTrace), ).toEqual([]) - const hostile: RemoteLeaseTakeoverTrace = structuredClone( + const mutant: RemoteLeaseTakeoverTrace = structuredClone( expectedRemoteLeaseTakeoverTrace, ) - hostile.retiredOwnerAfterTakeover = { + mutant.retiredOwnerAfterTakeover = { loads: 1, unloads: 0, active: 1, exactUnloads: true, } - hostile.newOwnerAfterTakeover = { + mutant.newOwnerAfterTakeover = { loads: 0, unloads: 0, active: 0, exactUnloads: true, } - hostile.newOwnerAfterRelease.exactUnloads = false + mutant.newOwnerAfterRelease.exactUnloads = false expect( - remoteLeaseTakeoverViolations(hostile).map( + remoteLeaseTakeoverViolations(mutant).map( ({ checkpoint, field }) => `${checkpoint}:${field}`, ), ).toEqual([ @@ -1255,6 +1268,21 @@ describe(`remote subset ownership lease oracle`, () => { let run = 0 try { + const campaignParameters = { + ...campaign.parameters, + examples: [ + [ + { + siblingCount: 2, + releasePrefix: 1, + identical: true, + duplicateRelease: true, + reverseFinalRelease: true, + }, + ], + ], + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(remoteLeaseHistoryArbitrary, async (history) => { const firstOwner = new RemoteLeaseOwnerLedger() @@ -1455,20 +1483,7 @@ describe(`remote subset ownership lease oracle`, () => { ) if (semanticFailure !== NO_PRIMARY_FAILURE) throw semanticFailure }), - { - ...campaign.parameters, - examples: [ - [ - { - siblingCount: 2, - releasePrefix: 1, - identical: true, - duplicateRelease: true, - reverseFinalRelease: true, - }, - ], - ], - }, + campaignParameters, ) } catch (error) { propertyFailure = error @@ -1568,6 +1583,7 @@ async function readRawCollectionSnapshot( describe(`per-collection adapter and SQLite-state oracle`, () => { it(`preserves alpha rows, schema, and reset epoch when beta registers a different adapter`, async () => { + oracleRoute(`fixed history`) let directory: string | undefined let database: BrowserWASQLiteDatabase | undefined let coordinator: BrowserCollectionCoordinator | undefined @@ -1663,6 +1679,7 @@ describe(`per-collection adapter and SQLite-state oracle`, () => { }) it(`initializes one fresh collection safely across matching adapter instances`, async () => { + oracleRoute(`fixed history`) let directory: string | undefined let database: BrowserWASQLiteDatabase | undefined @@ -1736,7 +1753,8 @@ describe(`per-collection adapter and SQLite-state oracle`, () => { ]) }) - it(`calibrates SQLite-state and fresh-registry checks against hostile results`, () => { + it(`calibrates SQLite-state and fresh-registry checks against mutant results`, () => { + oracleRoute(`calibration`) const expected: RawCollectionSnapshot = { registryRowCount: 1, schemaVersion: 11, @@ -2006,6 +2024,7 @@ async function requestBothCollections( describe(`generated collection-route histories`, () => { it(`calibrates collection-key comparison against one peer-routed apply`, () => { + oracleRoute(`calibration`) const expected: Array = [ { phase: `initial-owner`, @@ -2071,6 +2090,7 @@ describe(`generated collection-route histories`, () => { }) it(`routes every adapter-bound RPC by collection before and after leadership transfer`, async () => { + oracleRoute(`fixed history`) const makePullResult = ( marker: string, latestRowVersion: number, @@ -2335,6 +2355,7 @@ describe(`generated collection-route histories`, () => { NO_PRIMARY_FAILURE try { + oracleRoute(oracleRouteOf(campaign.parameters)) await fc.assert( fc.asyncProperty(routingHistoryArbitrary, async (history) => { const plan = routingGrammarPlan(history) @@ -2591,6 +2612,7 @@ function findUnownedWrites( describe(`sync-ingested write ownership oracle`, () => { it(`applies a source commit through the elected persistence owner after leadership changes`, async () => { + oracleRoute(`fixed history`) type Todo = { id: string; title: string } type SourceParams = Parameters[`sync`]>[0] @@ -2703,6 +2725,7 @@ describe(`sync-ingested write ownership oracle`, () => { }) it(`calibrates the owner checker against one bypass and one owned write`, () => { + oracleRoute(`calibration`) expect( findUnownedWrites( [ @@ -2725,11 +2748,11 @@ 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 -`persistence.follower-remote-demand` lane and the follower generation-notice +`persistence.follower-remote-demand` property 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 +fixed histories: their rotation cell is a fixed history that fails under the known limit +#2139, and the fix adds their generated campaign back. This production path is their receiving witness across the real boundary: two tabs, each with its own real BrowserCollectionCoordinator over Node's real BroadcastChannel and Web Locks, @@ -2751,7 +2774,7 @@ owner's row, and for the queued placement it holds the other subset's hydration read. It does not reorder the channel, which stays FIFO as in a browser. The custom source delivers each row once; a source that re-inserts an existing row on every ensure made the follower release and request its -demand again without end, on main as well, and is outside this lane. +demand again without end, on main as well, and is outside this production path. Checkpoint and observations: once the owner's reply has reached the follower and the apply is still outstanding, whether the consumer has settled. After @@ -2769,7 +2792,7 @@ persistence. Rotation: when only the follower's claim expires, it rotates into a generation no other tab joins and becomes that generation's owner. The -rotation cell pins that its rows stay visible and new demand settles from its +rotation cell checks that its rows stay visible and new demand settles from its own source. Another tab never owns the rotated generation in this topology, so the elected-owner notice path has no receiving witness here. */ @@ -2975,7 +2998,10 @@ describe(`follower remote-demand settlement across real tabs`, () => { histories.push({ source, placement, apply, consumer }) it.each(histories)( `settles a follower $consumer with the owner's rows: $source source, $placement, apply $apply`, - (history) => assertRealTabFollowerHistory(history), + (history) => { + oracleRoute(`case`) + return assertRealTabFollowerHistory(history) + }, 20_000, ) }) @@ -2985,6 +3011,7 @@ describe(`follower remote-demand settlement across real tabs`, () => { // independent of the coordinator and its adapter routing. Declared last // because the model classes are defined above and cannot be read earlier. declareOracle({ + owns: [`src/browser-coordinator.ts`], check: [ subsetWireViolations, subsetAckViolations, diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts index 521a9c7bca..5341450121 100644 --- a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-support.ts @@ -758,7 +758,7 @@ export function routingGrammarPlan( } // Node's own BroadcastChannel and Web Locks, captured before any test installs -// the controlled seams. This lane swaps them in for its body only. +// the controlled seams. This test swaps them in for its body only. export const nativeBroadcastChannel = globalThis.BroadcastChannel export const nativeNavigator = globalThis.navigator diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts index d5ab71d7c4..8cb0c131d6 100644 --- a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator.test.ts @@ -1,7 +1,7 @@ // Scenario tests for the per-collection coordinator: wire and acknowledgement // partitioning, persisted cache generation routing, remote subset lease // bookkeeping, per-collection adapter routing, SQLite state, sync-ingested -// ownership, and follower settlement across real tabs. Each test pins one +// ownership, and follower settlement across real tabs. Each test checks one // scenario with hand-written expectations. The laws these scenarios touch are // owned by per-collection-coordinator-oracle.test.ts, which generates histories // and compares production with independent models. @@ -1726,7 +1726,7 @@ describe(`follower remote-demand settlement across real tabs`, () => { // This topology never routes to another tab's owner, so the // 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. + // is a fixed history that fails under the known limit #2139. it( `keeps a rotated follower's rows and serves its new demand from its own generation`, () => diff --git a/packages/browser-db-sqlite-persistence/tests/reload-class-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/reload-class-oracle.test.ts index e25d678691..641ff7be1e 100644 --- a/packages/browser-db-sqlite-persistence/tests/reload-class-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/reload-class-oracle.test.ts @@ -20,7 +20,14 @@ import { join } from 'node:path' import BetterSqlite3 from 'better-sqlite3' import { test as fcTest } from '@fast-check/vitest' import { describe, expect, onTestFinished } from 'vitest' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { fixedReloadHistories, inProcessSeams, @@ -120,29 +127,43 @@ async function expectLawful(history: ReloadHistory) { expect(violations).toEqual([]) } +declareOracle({ + owns: [`src/browser-coordinator.ts`, `src/browser-persistence.ts`], + check: [expectLawful], + grammar: [reloadHistoryArb], +}) + describe(`reload class oracle (Node Browser driver)`, () => { fcTest.each( fixedReloadHistories.map(([name, history]) => ({ name, history })), )( `keeps a reload free of avoidable work: $name`, async ({ history }) => { + oracleRoute(`fixed history`) await expectLawful(history) }, 30_000, ) - fcTest.prop([reloadHistoryArb], { seed: 2204, numRuns: oracleRuns(12) })( + const reloadHistoryFixedSeed = oracleFixedSeed(2204, { + numRuns: oracleRuns(12), + }) + fcTest.prop([reloadHistoryArb], reloadHistoryFixedSeed)( `keeps generated reloads lawful (fixed seed)`, - expectLawful, + (history) => { + oracleRoute(oracleRouteOf(reloadHistoryFixedSeed)) + return expectLawful(history) + }, 300_000, ) - fcTest.prop( - [reloadHistoryArb], - oraclePropertyOptions(12, `sqlite-reload.class`), - )( + const reloadHistoryCampaign = oraclePropertyOptions(12, `sqlite-reload.class`) + fcTest.prop([reloadHistoryArb], reloadHistoryCampaign)( `keeps generated reloads lawful (random or replayed)`, - expectLawful, + (history) => { + oracleRoute(oracleRouteOf(reloadHistoryCampaign)) + return expectLawful(history) + }, 300_000, ) }) diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts index 38cb256ae3..46bcdaf859 100644 --- a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts @@ -1,15 +1,15 @@ /** * Node campaign for the shared-driver K=1 contract documented in * `shared-driver-fairness-oracle.ts`. Fixed histories prove neutral reach and a - * persist storm; generated legal histories vary both lane sizes, mutation + * persist storm; generated legal histories vary the sizes of both lanes, mutation * width, and tail order. Public rows and logical completion checkpoints are * checked independently of production scheduling, and an executable * named persist-first FIFO wrong answer proves the checker rejects the original * fault. Grammar controls reconstruct that witness, exercise the bounded * marginals, reject a nearby invalid storm, and ablate tail-order variation. * The identical generated property runs in retained fixed-seed and seedless - * random lanes. Supplying both TANSTACK_DB_DRIVER_FAIRNESS_SEED and - * TANSTACK_DB_DRIVER_FAIRNESS_PATH replaces those lanes with one checked + * random campaigns. Supplying both TANSTACK_DB_DRIVER_FAIRNESS_SEED and + * TANSTACK_DB_DRIVER_FAIRNESS_PATH replaces those campaigns with one checked * seed+shrink-path replay. An axis-removal calibration proves that removing * tail-order variation loses hydrate-before-later-persist histories. * The shared fixture holds the first `BEGIN IMMEDIATE` while cold `preload()` @@ -25,7 +25,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import fc from 'fast-check' import { describe, expect, it } from 'vitest' -import { oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { SHARED_DRIVER_FAIRNESS_BOUND, @@ -279,6 +284,7 @@ const generatedHistoryArbitrary = createGeneratedHistoryArbitrary(`varied`) const tailOrderAblatedArbitrary = createGeneratedHistoryArbitrary(`removed`) declareOracle({ + owns: [`src/wa-sqlite-driver.ts`], grammar: [generatedHistoryArbitrary, tailOrderAblatedArbitrary], }) let generatedPropertyExecutions = 0 @@ -334,6 +340,7 @@ const generatedFairnessProperty = fc.asyncProperty( describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { it(`reconstructs the known persist-first witness, covers range marginals, and rejects a nearby invalid storm`, async () => { + oracleRoute(`calibration`) const knownWitness = buildGeneratedHistory(2, 4, 1, [ { kind: `persist`, token: `p1` }, { kind: `persist`, token: `p2` }, @@ -401,6 +408,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }) it(`reaches cold hydration and preserves independently seeded public rows without queued persists`, async () => { + oracleRoute(`fixed history`) await withNodeScenario( createScenario(`neutral-reach`, [`hydrate`, `hydrate`, `hydrate`]), (observation) => { @@ -417,6 +425,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }) it(`completes a pending cold hydrate before an unrelated persist backlog drains and distinguishes K=1 from K=2`, async () => { + oracleRoute(`fixed history`) const scenario = createScenario( `fixed-persist-storm`, [`persist`, `persist`, `persist`, `hydrate`, `hydrate`], @@ -521,14 +530,16 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { it.each(fairnessPropertyModes)( `bounds persist completions for generated ordered cold-hydrate/persist histories in $label mode`, async (mode) => { - const executionsBefore = generatedPropertyExecutions - const failuresBefore = generatedFailureRecords.length - const result = await fc.check(generatedFairnessProperty, { + const parameters = { numRuns: oracleRuns(GENERATED_RUNS), - verbose: 2, - ...(mode.seed === undefined ? {} : { seed: mode.seed }), + verbose: 2 as const, + ...(mode.seed === undefined ? {} : oracleFixedSeed(mode.seed)), ...(mode.path === undefined ? {} : { path: mode.path }), - }) + } + oracleRoute(oracleRouteOf(parameters)) + const executionsBefore = generatedPropertyExecutions + const failuresBefore = generatedFailureRecords.length + const result = await fc.check(generatedFairnessProperty, parameters) if (result.failed) { const failures = generatedFailureRecords.slice(failuresBefore) const original = failures[0] @@ -598,6 +609,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { } it(`keeps the K=1 interleaving and waits only for the hydrate queued before it`, async () => { + oracleRoute(`fixed history`) let baseline: ReadonlyArray | undefined await withNodeScenario(withoutBarrier, (observation) => { expectFairObservation(observation) @@ -615,6 +627,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }) it(`rejects an empty regular task used as the barrier`, async () => { + oracleRoute(`calibration`) let baseline: ReadonlyArray | undefined await withNodeScenario(withoutBarrier, (observation) => { baseline = observation.logicalCompletionOrder @@ -640,6 +653,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }) it(`rejects an empty hydrate scope used as the barrier`, async () => { + oracleRoute(`calibration`) let failure: unknown await withNodeScenario( withBarrier, @@ -660,6 +674,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }) it(`proves tail-order ablation removes a hydrate-before-later-persist history`, () => { + oracleRoute(`calibration`) const sampleOptions = { seed: FIXED_SEED, numRuns: oracleRuns(GENERATED_RUNS), @@ -677,6 +692,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }) it(`rejects the named persist-first FIFO wrong answer through the real package path`, async () => { + oracleRoute(`calibration`) await withNodeScenario( createScenario( `persist-first-fixture-fault`, @@ -701,6 +717,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }) it(`calibrates the checker against a synthetic persist-first observation`, () => { + oracleRoute(`calibration`) const scenario = createScenario( `persist-first-checker-calibration`, [`persist`, `persist`, `persist`, `persist`, `hydrate`, `hydrate`], diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts index 43362d9f80..2255b9a168 100644 --- a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.ts @@ -46,14 +46,14 @@ import { declareOracle } from '../../db/tests/oracle-declaration.js' * let an additional persist through or change the interleaving. The scheduler * trace records hydrate scope ends and barrier settlement, so a test can * check that the barrier waited for the work queued before it and not for - * later work. Two hostile controls take a scheduler turn instead: + * later work. Two mutant controls take a scheduler turn instead: * `empty-hydrate` enqueues an empty hydrate scope, which K=1 counts as a * hydrate, and `empty-regular` enqueues an empty regular task, which K=1 * counts as the persist slot after a hydrate. * * Challenge and replay: the named persist-first FIFO wrong answer must violate * the same K=1 checker while using the public/core/driver path. The generated - * property has fixed-seed and seedless-random lanes. Supplying both + * property has fixed-seed and seedless-random campaigns. Supplying both * TANSTACK_DB_DRIVER_FAIRNESS_SEED and TANSTACK_DB_DRIVER_FAIRNESS_PATH runs * only their checked oracle replay. Tail-order ablation must lose the legal * hydrate-before-later-persist cell. The grammar also reconstructs that known @@ -155,9 +155,9 @@ export type SharedDriverFairnessViolation = { } export type SharedDriverFairnessOptions = { - /** Test-only hostile control that preserves the current global FIFO fault. */ + /** Test-only mutant control that preserves the current global FIFO fault. */ schedulingFault?: `persist-first-fifo` - /** Test-only hostile barriers that take a scheduler turn K=1 counts. */ + /** Test-only mutant barriers that take a scheduler turn K=1 counts. */ barrierFault?: `empty-hydrate` | `empty-regular` } @@ -296,7 +296,7 @@ class ObservedDatabase implements BrowserWASQLiteDatabase { } /** - * A test-only hostile scheduler. It admits calls through the same public/core/ + * A test-only mutant scheduler. It admits calls through the same public/core/ * BrowserWASQLiteDriver path but serializes them in global admission order, so * a future fair production driver cannot accidentally make this control pass. */ @@ -659,7 +659,7 @@ export function findSharedDriverFairnessLawViolations( return violations } -/** Checker calibration only; the executable hostile control uses the fixture. */ +/** Checker calibration only; the executable mutant control uses the fixture. */ export function createPersistFirstFaultObservation( scenario: SharedDriverFairnessScenario, ): SharedDriverFairnessObservation { @@ -965,6 +965,8 @@ export async function observeSharedDriverFairness( return observation } +// Owns nothing: this module holds the model and check only. The production +// path is driven by shared-driver-fairness-oracle.test.ts, which declares the owned entry. declareOracle({ check: [ findSharedDriverFairnessViolation, diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts index 62b76239a2..33b9e3b73d 100644 --- a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness.test.ts @@ -7,10 +7,10 @@ import { } from './shared-driver-fairness-config' // Configuration tests for the shared BrowserWASQLiteDriver fairness oracle in -// `shared-driver-fairness-oracle.test.ts`, which owns the law. These pin the replay +// `shared-driver-fairness-oracle.test.ts`, which owns the law. These check the replay // and calibration environment parsing. describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { - it(`selects fixed and seedless lanes by default and only replay when requested`, () => { + it(`selects fixed-seed and random campaigns by default and replays only when requested`, () => { expect(createFairnessPropertyModes(readFairnessReplayConfig({}))).toEqual([ { label: `fixed-seed`, seed: FIXED_SEED }, { label: `seedless-random` }, diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index fc54fa5466..c0ff97ad06 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -382,9 +382,7 @@ it.each([`insert`, `update`, `delete`] as const)( draft(fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n })), draft(fc.constantFrom(NaN, Infinity, -Infinity, -0)), draft( - fc - .uint8Array({ maxLength: 12 }) - .map((bytes) => bytes.buffer as ArrayBuffer), + fc.uint8Array({ maxLength: 12 }).map((bytes) => bytes.buffer), ), ) const histories = fc.record({ @@ -407,7 +405,7 @@ it.each([`insert`, `update`, `delete`] as const)( ] // Each operation owns its replay grammar. One insert/update bundle contains // six process histories; three delete bundles contain six too. Fixed and - // random lanes therefore retain the same six-history budget per operation. + // random campaigns therefore retain the same six-history budget per operation. const replayPrefix = `CONVEX_BACKEND_${operation.toUpperCase()}_ORACLE` await runOracleCampaign( replayPrefix, @@ -420,9 +418,11 @@ it.each([`insert`, `update`, `delete`] as const)( uncertainFirst, !uncertainFirst, ]) { - for (const { text, rank, payload } of lostAcknowledgement - ? uncertain - : known) { + // The two payload tuples have different lengths, so the loop + // reads either one as an array of its drafts. + const drafts: ReadonlyArray<(typeof known)[number]> = + lostAcknowledgement ? uncertain : known + for (const { text, rank, payload } of drafts) { reach.beginHistory() await durableHistory( { diff --git a/packages/convex-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts b/packages/convex-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts index 907df5309e..f644b2e1cc 100644 --- a/packages/convex-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { describe, it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -49,6 +52,7 @@ async function campaign( const reach = createOracleReach(name, reachStatesFor(checked), { minimum: 5, }) + oracleRoute(oracleRouteOf(options)) await fc.assert(eagerLiveSourceProperty(checked, reach), options) return reach } @@ -57,7 +61,9 @@ describe(`eager live-source conformance: Convex`, () => { oracleReplayTest(it)( `conforms on a fixed-seed campaign`, async () => { - const reach = await campaign({ seed: 2118, numRuns: oracleRuns(runs) }) + const reach = await campaign( + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), + ) reach.assertReached() }, campaignTimeoutMs, @@ -77,7 +83,7 @@ describe(`eager live-source conformance: Convex`, () => { `conforms over the React client on a fixed-seed campaign`, async () => { const reach = await campaign( - { seed: 2118, numRuns: oracleRuns(runs) }, + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), convexConformanceDriver({ client: `react` }), `${property}.react`, ) @@ -92,7 +98,7 @@ describe(`eager live-source conformance: Convex`, () => { `conforms over a wrapped JavaScript client on a fixed-seed campaign`, async () => { const reach = await campaign( - { seed: 2118, numRuns: oracleRuns(runs) }, + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), convexConformanceDriver({ client: `wrapped` }), `${property}.wrapped`, ) @@ -103,6 +109,7 @@ describe(`eager live-source conformance: Convex`, () => { }) declareOracle({ + owns: [`src/index.ts#convexCollectionOptions`], check: [checkEagerLiveSourceHistory, eagerLiveSourceProperty], model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], grammar: [historyArb], diff --git a/packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts index ad1de20732..b8fa5a7d37 100644 --- a/packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-abort-replay-oracle.property.test.ts @@ -31,6 +31,7 @@ import { createLiveQueryCollection } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { lazyOracleRuns, receiveBothSdks } from './lazy-paging-campaign' @@ -38,6 +39,10 @@ import { expectedLazyRows } from './lazy-paging-model-oracle' import { LazyVersionModel } from './lazy-paging-version-model-oracle' import type { LazyRow } from './lazy-paging-model-oracle' +declareOracle({ + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) + const grammar = fc.record({ size: fc.integer({ min: 12, max: 18 }), pageSize: fc.integer({ min: 2, max: 3 }), diff --git a/packages/convex-db-collection/tests/lazy-paging-driver.ts b/packages/convex-db-collection/tests/lazy-paging-driver.ts index e2a3d817a4..4437f98632 100644 --- a/packages/convex-db-collection/tests/lazy-paging-driver.ts +++ b/packages/convex-db-collection/tests/lazy-paging-driver.ts @@ -425,7 +425,7 @@ export function lazyFixture( } batches.push(batch) for (const listener of batchListeners) listener(batch) - // Hostile receiver controls may lie to the adapter while the observer + // Mutant receiver controls may lie to the adapter while the observer // retains the actual core receipt and its true application boundary. return adapterReceipt ? adapterReceipt(receipt) : receipt }, diff --git a/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts index 6a4ef96e97..bf2b8f3b34 100644 --- a/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-failure-history-oracle.property.test.ts @@ -26,7 +26,8 @@ import { createLiveQueryCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { @@ -39,6 +40,10 @@ import { import { LazyReceiptModel } from './lazy-paging-receipt-model' import type { LazyRow } from './lazy-paging-model-oracle' +declareOracle({ + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) + const drain = async () => { for (let i = 0; i < 30; i++) await Promise.resolve() } @@ -433,6 +438,7 @@ it(`composes native failures, shared holds, retirement and terminal restart`, as }, 60000) it(`rejects old graphs recovering or changing after cleanup`, () => { + oracleRoute(`calibration`) const row = { id: `one`, rank: 1, text: `authored` } const model = new LazyTerminalGraphModel() model.cleanup([row]) diff --git a/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts index 43813e2f0a..93b4c9f8ba 100644 --- a/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-history-oracle.property.test.ts @@ -21,7 +21,13 @@ import fc from 'fast-check' import { createTransaction } from '@tanstack/db' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { @@ -32,6 +38,10 @@ import { } from './lazy-paging-model-oracle' import type { LazyRow } from './lazy-paging-model-oracle' +declareOracle({ + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) + const commands = fc.array( fc.record({ action: fc.constantFrom( @@ -444,6 +454,8 @@ it(`compares paired hidden suffixes at the production discovery boundary`, async * strategies. An overshot row may be kept, or dropped and reacquired before a * later deeper success. Assuming it was retained after dropping it is illegal. */ it(`accepts retained and reacquired overshoot while rejecting a lost row`, () => { + const parameters = oracleFixedSeed(20261027, { numRuns: oracleRuns(120) }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property( fc.integer({ min: 2, max: 8 }), @@ -467,6 +479,6 @@ it(`accepts retained and reacquired overshoot while rejecting a lost row`, () => assertLazyCoverage(reacquired, source, { limit: size }, descending) }, ), - { seed: 20261027, numRuns: oracleRuns(120) }, + parameters, ) }) diff --git a/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts index 4e1b7a1fd2..c758ad017d 100644 --- a/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-interleaving-oracle.property.test.ts @@ -19,11 +19,15 @@ import { createCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { LazyReceiptModel } from './lazy-paging-receipt-model' import type { SyncAppliedReceipt, SyncConfig } from '@tanstack/db' +// Owns no convex source: this grammar drives @tanstack/db Collection sync with its own SyncConfig, not the convex pager. +declareOracle({}) + type Row = { id: string } const gate = () => { let resolve!: () => void @@ -396,6 +400,7 @@ it(`generates legal response/receipt/application/cancellation interleavings`, as it.each([`borrow-peer`, `first-only`, `premature-inflight-cancel`] as const)( `rejects %s under interleaving`, async (fault) => { + oracleRoute(`calibration`) await expect( runInterleaving( { @@ -413,6 +418,7 @@ it.each([`borrow-peer`, `first-only`, `premature-inflight-cancel`] as const)( ) it(`permits core fulfillment after abort once an uncancelable baseline applies`, () => { + oracleRoute(`case`) const model = new LazyReceiptModel() model.acquire(`a`, [`v1`]) model.initiate(`a`, `baseline`) diff --git a/packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts index c730be9bc8..0ae29ec739 100644 --- a/packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-load-outcome-oracle.property.test.ts @@ -23,6 +23,7 @@ import { IR, createCollection, createLiveQueryCollection } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyOracleRuns } from './lazy-paging-campaign' import { expectedLazyRows } from './lazy-paging-model-oracle' @@ -30,6 +31,9 @@ import { LazyVersionModel } from './lazy-paging-version-model-oracle' import type { LazyRow } from './lazy-paging-model-oracle' import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' +// Owns no convex source: this grammar drives @tanstack/db Collection and live queries with its own SyncConfig, not the convex pager. +declareOracle({}) + const grammar = fc.record({ count: fc.integer({ min: 1, max: 4 }), text: fc.string({ maxLength: 10 }), diff --git a/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts index 1e9eaba0ee..77c8bdd7b1 100644 --- a/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-native-receipt-oracle.property.test.ts @@ -16,13 +16,18 @@ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { LazyReceiptModel } from './lazy-paging-receipt-model' import { ordered } from './lazy-paging-model-oracle' import type { LazyRow } from './lazy-paging-model-oracle' +declareOracle({ + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) + const turns = async () => { for (let i = 0; i < 25; i++) await Promise.resolve() } @@ -296,6 +301,7 @@ it(`refines the same receipt model through native uniquely establishing batches` }, 60000) it(`rejects a native receiver borrowing the applied startup receipt`, async () => { + oracleRoute(`calibration`) await expect( nativeHistory( { diff --git a/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts index 9dc15d3d31..dceef5879d 100644 --- a/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-oracle.property.test.ts @@ -28,8 +28,9 @@ import { } from '@tanstack/db' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' import { convexPaginatedCollectionOptions } from '../src/index' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyFixture, @@ -47,6 +48,10 @@ import { } from './lazy-paging-model-oracle' import type { LazyRow } from './lazy-paging-model-oracle' +declareOracle({ + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) + const history = fc.record({ size: fc.integer({ min: 16, max: 32 }), pageSize: fc.integer({ min: 1, max: 5 }), @@ -869,6 +874,7 @@ it(`keeps the newer establishing receipt through publication reentry`, async () // of the production driver. Legal batch/cached and reacquisition behavior // carries no mandatory second-receipt or retained-tail assertion. it(`rejects wrong coverage and transient windows while preserving discovery freedom`, () => { + oracleRoute(`calibration`) const rows = [ { id: `a`, rank: 1, text: `` }, { id: `b`, rank: 1, text: `` }, @@ -898,6 +904,7 @@ it(`rejects wrong coverage and transient windows while preserving discovery free it.each([0, -1, 1.5, Infinity, NaN])( `rejects invalid lazy row caps before readiness: %s`, async (maxRows) => { + oracleRoute(`case`) const f = lazyFixture([], 1) await f.source.cleanup() const source = createCollection( diff --git a/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts index 0ba8aff8ff..55dcac7af8 100644 --- a/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-receipt-oracle.property.test.ts @@ -23,7 +23,8 @@ import { createCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { LazyReceiptModel } from './lazy-paging-receipt-model' import type { @@ -32,6 +33,9 @@ import type { SyncConfig, } from '@tanstack/db' +// Owns no convex source: this grammar drives @tanstack/db Collection receipts with its own adapter, not the convex pager. +declareOracle({}) + type Row = { id: string; text: string } const gate = () => { let resolve!: () => void @@ -414,6 +418,7 @@ it.each([ ] as const)( `rejects the %s faulty adapter at the real Collection boundary`, async (fault) => { + oracleRoute(`calibration`) await expect( receiptHistory( { diff --git a/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts index 055a0e6f0a..72055d8663 100644 --- a/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-revalidation-oracle.property.test.ts @@ -26,6 +26,8 @@ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' +import { oracleRoute } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { lazyOracleRuns, receiveBothSdks } from './lazy-paging-campaign' @@ -34,6 +36,10 @@ import { LazyReceiptModel } from './lazy-paging-receipt-model' import { LazyVersionModel } from './lazy-paging-version-model-oracle' import type { LazyRow } from './lazy-paging-model-oracle' +declareOracle({ + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) + const turns = async () => { for (let i = 0; i < 30; i++) await Promise.resolve() } @@ -338,6 +344,7 @@ it(`receives equal-valued and superseding revalidation through actual receipts`, }, 60000) it(`rejects applied-content substitution for an equal-valued held receipt`, async () => { + oracleRoute(`calibration`) await expect( receive( { size: 5, pageSize: 2, descending: false, cancelFirst: true, text: `` }, @@ -348,6 +355,7 @@ it(`rejects applied-content substitution for an equal-valued held receipt`, asyn }) it(`calibrates cleanup authority without treating cleanup as receipt application`, () => { + oracleRoute(`calibration`) const model = new LazyReceiptModel() model.acquire(`a`, [`equal`]) model.accept(`actual`, [`equal`], [`a`]) @@ -368,6 +376,7 @@ it(`calibrates cleanup authority without treating cleanup as receipt application }) it(`calibrates exact window order while accepting coherent skipped revisions`, () => { + oracleRoute(`calibration`) const a = { id: `a`, rank: 0, text: `old` } const b = { id: `b`, rank: 1, text: `old` } const latest = [ diff --git a/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts b/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts index 51312d762c..6ff0783208 100644 --- a/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-version-oracle.property.test.ts @@ -26,13 +26,18 @@ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { assertLazySourceFacts } from './lazy-paging-model-oracle' import { LazyVersionModel } from './lazy-paging-version-model-oracle' import type { LazyRow } from './lazy-paging-model-oracle' +declareOracle({ + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) + const history = fc.record({ size: fc.integer({ min: 8, max: 14 }), pageSize: fc.integer({ min: 2, max: 4 }), @@ -84,7 +89,7 @@ async function runVersions(h: History, fault: Fault = `none`) { const publish = () => { const rows = [...f.source.base.values()].map((row) => ({ ...row })) compare(rows) - // Hostile publication controls run at the same observation boundary, then + // Mutant publication controls run at the same observation boundary, then // restore the correct final value. They test trace comparison, not a claim // that production itself emitted these deliberately injected observations. if (!injected && fault !== `none` && rows[0]?.text.endsWith(`revision-2`)) { @@ -210,6 +215,7 @@ it(`checks every native publication against coherent monotone authored revisions it.each([`rollback`, `mixed`] as const)( `rejects a transient %s publication even with the correct final revision`, async (fault) => { + oracleRoute(`calibration`) await expect( runVersions( { @@ -229,6 +235,7 @@ it.each([`rollback`, `mixed`] as const)( ) it(`allows stuttering, skipped and observationally identical revisions`, () => { + oracleRoute(`case`) const a = [{ id: `a`, rank: 0, text: `first` }] const b = [{ id: `a`, rank: 0, text: `middle` }] const c = [{ id: `a`, rank: 0, text: `last` }] diff --git a/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts index 4df2dd7984..63bc1896e8 100644 --- a/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts @@ -1,10 +1,16 @@ import { expect, it } from 'vitest' +import { oracleRoute } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { LazyWriteModel } from './lazy-paging-write-model-oracle' +// Owns no convex source: this file calibrates LazyWriteModel directly, without a production driver. +declareOracle({}) + /** Calibration of the two-sided law, independently of a backend driver. The * same source row distinguishes a confirmed lost write from a rejected write * that leaked remotely. These controls preserve both permitted counterparts. */ it(`rejects lost confirmations and remote effects of atomic rejection`, () => { + oracleRoute(`calibration`) const original = { id: `a`, rank: 1, text: `before` } const changed = { ...original, rank: 0, text: `after` } for (const confirmed of [false, true]) { @@ -37,6 +43,7 @@ it(`rejects lost confirmations and remote effects of atomic rejection`, () => { /** A rejected overlay can reveal an older applied source while a newer remote * revision waits. After the newer source is public, the old one cannot return. */ it(`separates optimistic rollback from source rollback and atomic source mixing`, () => { + oracleRoute(`calibration`) const a = { id: `a`, rank: 0, text: `a0` } const b = { id: `b`, rank: 1, text: `b0` } const nextA = { ...a, text: `a1` } diff --git a/packages/convex-db-collection/tests/offline-oracle.property.test.ts b/packages/convex-db-collection/tests/offline-oracle.property.test.ts index 70c53176a3..ad9aa869aa 100644 --- a/packages/convex-db-collection/tests/offline-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/offline-oracle.property.test.ts @@ -20,11 +20,19 @@ import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { oracleRuns } from '../../db/tests/oracle-config' import { convexCollectionOptions } from '../src/index' import { convexOfflineMutation } from '../src/offline' +import { declareOracle } from '../../db/tests/oracle-declaration' import { controlledClient, query, withTestCleanup } from './helpers' import type { Row } from './helpers' import type { ConvexClient } from 'convex/browser' import type { OfflineConfig } from '@tanstack/offline-transactions' +declareOracle({ + owns: [ + `src/index.ts#convexCollectionOptions`, + `src/offline.ts#convexOfflineMutation`, + ], +}) + const mutation = makeFunctionReference< `mutation`, { idempotencyKey: string; text: string }, diff --git a/packages/convex-db-collection/tests/ownership-oracle.property.test.ts b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts index 80eadc03cd..09f46ef6a7 100644 --- a/packages/convex-db-collection/tests/ownership-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/ownership-oracle.property.test.ts @@ -21,8 +21,13 @@ import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { oracleRuns } from '../../db/tests/oracle-config' import { convexCollectionOptions } from '../src/index' +import { declareOracle } from '../../db/tests/oracle-declaration' import { controlledClient, query, withTestCleanup } from './helpers' +declareOracle({ + owns: [`src/index.ts#convexCollectionOptions`], +}) + const forms = [`direct`, `shallow`, `fresh`, `descriptor`] as const const checkpoints = [`idle`, `ready`, `cleaned-up`] as const const states = [ diff --git a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts index f385726738..704abd9e69 100644 --- a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts @@ -26,7 +26,7 @@ import { createCollection } from '@tanstack/db' import { expect, it } from 'vitest' import { convexPaginatedCollectionOptions } from '../src/index' import { runOracleCampaign } from '../../db/tests/oracle-campaign' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { copyRows, keyedRows, provider, query } from './pagination-support' @@ -108,7 +108,11 @@ function expectRevisionTrace( // The check below compares each observed snapshot with the model's prior and // current complete snapshots; the grammar draws the source histories. -declareOracle({ check: [expectRevisionTrace], grammar: [history, snapshot] }) +declareOracle({ + check: [expectRevisionTrace], + grammar: [history, snapshot], + owns: [`src/paginated-options.ts#convexPaginatedCollectionOptions`], +}) // Calibrate both recorders' shared trace relation without imposing one callback // per revision. Repeated old/new snapshots are legal; prefixes and rollback @@ -116,6 +120,7 @@ declareOracle({ check: [expectRevisionTrace], grammar: [history, snapshot] }) it.each([`select`, `Collection publication`])( `calibrates complete monotone snapshots at %s`, (boundary) => { + oracleRoute(`calibration`) const current = Array.from({ length: 3 }, (_, rank) => ({ id: String(rank), rank, diff --git a/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts b/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts index b5661e29e3..63bcc71a94 100644 --- a/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts +++ b/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts @@ -9,6 +9,8 @@ import { ConvexClient } from 'convex/browser' import { ConvexReactClient } from 'convex/react' import { makeFunctionReference } from 'convex/server' import { expect, it } from 'vitest' +import { oracleRoute } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { assertLazyPublications, expectedLazyRows, @@ -17,6 +19,9 @@ import { withTestCleanup } from './helpers' import type { LazyRow } from './lazy-paging-model-oracle' import type { PaginationOptions, PaginationResult } from 'convex/server' +// Owns no convex source: this receiving premise drives the Convex SDK clients, not the convex collection options. +declareOracle({}) + type Outgoing = { type: string newVersion: number @@ -53,6 +58,7 @@ const timestamp = (value: number) => { it.each([`javascript`, `react`] as const)( `reads coherent range caches before every %s SDK callback`, async (kind) => { + oracleRoute(`case`) const options = { webSocketConstructor: Socket as unknown as typeof WebSocket, unsavedChangesWarning: false, diff --git a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts index ec4bd04683..300c895e76 100644 --- a/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/snapshot-oracle.property.test.ts @@ -10,7 +10,7 @@ * Every generated history covers valid and duplicate starts, then constructs * omission, field removal, empty results, * reintroduction, duplicates and recovery, then appends arbitrary snapshots. - * String, numeric and mixed key lanes share that history. The mixed lane + * String, numeric and mixed key grammars share that history. The mixed grammar * includes 1/'1'; finite numeric parameters include fractions and negatives. * Raw and selected runs compare the same law. Selection changes key shape and * uppercases text; the reference states that transformation independently. @@ -32,9 +32,14 @@ import { expect, it } from 'vitest' import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { oracleRuns } from '../../db/tests/oracle-config' import { convexCollectionOptions } from '../src/index' +import { declareOracle } from '../../db/tests/oracle-declaration' import { controlledClient, withTestCleanup } from './helpers' import type { Row } from './helpers' +declareOracle({ + owns: [`src/index.ts#convexCollectionOptions`], +}) + type KeyedRow = Row & { key: string | number } const query = makeFunctionReference< `query`, @@ -77,24 +82,24 @@ it(`refines complete snapshots throughout generated histories`, async () => { }), async (numberKey, text, suffix) => { reach.beginHistory() - const lanes = [ + const keyKinds = [ { name: `string-keys`, keys: [`a`, `b`, `c`] }, { name: `numeric-keys`, keys: [numberKey, numberKey + 0.5, 1] }, { name: `mixed-key-types`, keys: [1, `1`, numberKey] }, ] as const - for (const lane of lanes) { + for (const keyKind of keyKinds) { const first: KeyedRow = { _id: `first`, - key: lane.keys[0], + key: keyKind.keys[0], text: `lower:${text}`, optional: `remove`, } const second: KeyedRow = { _id: `second`, - key: lane.keys[1], + key: keyKind.keys[1], text, } - // The first two lane keys are always distinct. This constructive + // The first two keys are always distinct. This constructive // prefix forces adjacent laws even when the random suffix is empty. const history: Array> = [ [first, second], @@ -107,7 +112,7 @@ it(`refines complete snapshots throughout generated histories`, async () => { ...suffix.map((snapshot) => snapshot.map((value): KeyedRow => ({ _id: `source:${value.slot}`, - key: lane.keys[value.slot]!, + key: keyKind.keys[value.slot]!, text: value.text, ...(value.optional === undefined ? {} @@ -216,7 +221,7 @@ it(`refines complete snapshots throughout generated histories`, async () => { if (projected && selected.length > 0) reach.reach(`selected-key-representation`) if (!projected && selected.length > 0) - reach.reach(lane.name) + reach.reach(keyKind.name) if ( selected.some((value) => value.key === 1) && selected.some((value) => value.key === `1`) diff --git a/packages/convex-db-collection/tests/values-oracle.property.test.ts b/packages/convex-db-collection/tests/values-oracle.property.test.ts index f8cebcdd02..6968189366 100644 --- a/packages/convex-db-collection/tests/values-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/values-oracle.property.test.ts @@ -23,11 +23,16 @@ import { convexMutationCodec } from '../src/offline' import { cleanupOfflineOracle } from '../../offline-transactions/tests/oracle-lifecycle' import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { oracleRuns } from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import type { OfflineTransaction, StorageAdapter, } from '@tanstack/offline-transactions' +declareOracle({ + owns: [`src/offline.ts`], +}) + type Tree = | undefined | null @@ -46,7 +51,7 @@ const leaf: fc.Arbitrary = fc.oneof( fc.double({ noNaN: true, noDefaultInfinity: true }), fc.constantFrom(NaN, Infinity, -Infinity, -0), fc.bigInt({ min: -(2n ** 63n), max: 2n ** 63n - 1n }), - fc.uint8Array({ maxLength: 8 }).map((value) => value.buffer as ArrayBuffer), + fc.uint8Array({ maxLength: 8 }).map((value) => value.buffer), ) function tree(depth: number): fc.Arbitrary { if (!depth) return leaf diff --git a/packages/db-adapter-conformance/package.json b/packages/db-adapter-conformance/package.json index 38a065cf79..0372273012 100644 --- a/packages/db-adapter-conformance/package.json +++ b/packages/db-adapter-conformance/package.json @@ -48,7 +48,7 @@ ], "dependencies": { "@tanstack/db": "workspace:*", - "fast-check": "^3.23.2" + "fast-check": "^4.10.2" }, "devDependencies": { "@fast-check/vitest": "^0.5.0", diff --git a/packages/db-adapter-conformance/src/eager-live-source-oracle.ts b/packages/db-adapter-conformance/src/eager-live-source-oracle.ts index ff802aeed4..779c52d60d 100644 --- a/packages/db-adapter-conformance/src/eager-live-source-oracle.ts +++ b/packages/db-adapter-conformance/src/eager-live-source-oracle.ts @@ -2834,10 +2834,20 @@ export interface ReachCounter { * a smaller history that fails a different law, or fails outside the * comparison, counts as passing, so the reported counterexample violates the * law and observation that the campaign found. + * + * A calibration names the law it calibrates (`law`: the violation's whole + * law, such as `FID-1 (values)`, or a pattern that must match the whole law, + * for a mutant that either of two laws may reject). Only that law's + * violations fail a history then, so the calibration checks that this law + * rejects the mutant, whichever other law a generated history happens to + * break first. A name that is only part of a law, such as `FID-1` or + * `HEALTH-`, matches nothing, so a calibration cannot pass on a broader law + * than it names. */ export function eagerLiveSourceProperty( driver: EagerLiveSourceDriver, reach?: ReachCounter, + law?: string | RegExp, ) { let target: string | undefined return fc.asyncProperty( @@ -2849,17 +2859,34 @@ export function eagerLiveSourceProperty( reach: reach?.reach, }) } catch (error) { - const law = + const violated = error instanceof ConformanceViolation ? error.law : `failure outside the comparison` - target ??= law - if (law === target) throw error + const calibrated = law === undefined || namesLaw(law, violated) + if (calibrated) target ??= violated + if (violated === target) throw error } }, ) } +/** + * Whether a calibration's `law` names the violated law: the same string, or a + * pattern that matches the whole of it. The pattern is anchored at both ends, + * so an alternative that matches only a prefix of the law, as `EVID-2` does in + * `/EVID-2|EVID-2 \(awaitTxId \d+\)/`, cannot hide one that matches all of + * it. The `g`, `y`, and `m` flags are dropped: they would make the answer + * depend on the pattern's `lastIndex` or on one line of the law. + */ +export function namesLaw(law: string | RegExp, violated: string): boolean { + if (typeof law === `string`) return violated === law + return new RegExp( + `^(?:${law.source})$`, + law.flags.replace(/[gmy]/g, ``), + ).test(violated) +} + // Exported for calibration tests: the model's prediction for a history. export const eagerLiveSourceModel = { initial: initialModel, diff --git a/packages/db-adapter-conformance/src/index.ts b/packages/db-adapter-conformance/src/index.ts index 1d1b6a2c74..707cfda472 100644 --- a/packages/db-adapter-conformance/src/index.ts +++ b/packages/db-adapter-conformance/src/index.ts @@ -7,6 +7,7 @@ export { eagerLiveSourceProperty, eagerLiveSourceReachStates, jsonValueKinds, + namesLaw, reachStatesFor, } from './eager-live-source-oracle' export type { diff --git a/packages/db-adapter-conformance/tests/neon-realtime-oracle.property.test.ts b/packages/db-adapter-conformance/tests/neon-realtime-oracle.property.test.ts index 343ec1422e..4966121cc1 100644 --- a/packages/db-adapter-conformance/tests/neon-realtime-oracle.property.test.ts +++ b/packages/db-adapter-conformance/tests/neon-realtime-oracle.property.test.ts @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { describe, it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -46,6 +49,7 @@ async function campaign(options: fc.Parameters<[unknown]>) { const reach = createOracleReach(property, reachStatesFor(driver), { minimum: 5, }) + oracleRoute(oracleRouteOf(options)) await fc.assert(eagerLiveSourceProperty(driver, reach), options) return reach } @@ -54,7 +58,9 @@ describe(`eager live-source conformance: Neon Realtime adapter`, () => { oracleReplayTest(it)( `conforms on a fixed-seed campaign`, async () => { - const reach = await campaign({ seed: 2118, numRuns: oracleRuns(runs) }) + const reach = await campaign( + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), + ) reach.assertReached() }, campaignTimeoutMs, @@ -70,6 +76,8 @@ describe(`eager live-source conformance: Neon Realtime adapter`, () => { ) }) +// Owns nothing: the Neon adapter under test is vendored in tests/neon, and +// this package's src is the shared oracle itself. declareOracle({ check: [checkEagerLiveSourceHistory, eagerLiveSourceProperty], model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], diff --git a/packages/db-adapter-conformance/tests/reference-adapter-oracle.property.test.ts b/packages/db-adapter-conformance/tests/reference-adapter-oracle.property.test.ts index 6acb2655ff..23e6716622 100644 --- a/packages/db-adapter-conformance/tests/reference-adapter-oracle.property.test.ts +++ b/packages/db-adapter-conformance/tests/reference-adapter-oracle.property.test.ts @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -13,6 +16,7 @@ import { eagerLiveSourceHistoryArb, eagerLiveSourceModel, eagerLiveSourceProperty, + namesLaw, reachStatesFor, } from '../src' import { referenceDriver } from './reference-adapter' @@ -61,10 +65,20 @@ const runs = 600 // Timed waits sleep past their timeout, so a campaign takes several seconds. const campaignTimeoutMs = 120_000 +// A calibration names the whole law that its violation carries, such as +// `FID-1 (values)`, or a pattern that matches the whole law where the table +// accepts several. A part of a law, such as `FID-1`, names none, so a +// calibration cannot pass on a broader law than it names. The property's +// calibration mode and this check judge the name with the same `namesLaw`. +function expectNamedLaw(violated: string, law: string | RegExp) { + expect(namesLaw(law, violated), `${violated} is named by ${law}`).toBe(true) +} + async function campaign(options: fc.Parameters<[unknown]>) { const reach = createOracleReach(property, reachStatesFor(driver), { minimum: 5, }) + oracleRoute(oracleRouteOf(options)) await fc.assert(eagerLiveSourceProperty(driver, reach), options) return reach } @@ -73,7 +87,9 @@ describe(`eager live-source conformance: reference adapter`, () => { oracleReplayTest(it)( `conforms on a fixed-seed campaign`, async () => { - const reach = await campaign({ seed: 2118, numRuns: oracleRuns(runs) }) + const reach = await campaign( + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), + ) reach.assertReached() }, campaignTimeoutMs, @@ -88,67 +104,70 @@ describe(`eager live-source conformance: reference adapter`, () => { campaignTimeoutMs, ) - // Each mutant breaks one law; the check must report that law. + // Each mutant breaks one law; the check must report that law, by its whole + // name. A pattern accepts several observations of one law (the txid a wait + // names) or, where a comment says so, either of two laws. const mutants: Array<[ReferenceMutant, string | RegExp]> = [ - [`ready-before-delivery`, `READY-2`], - [`wraps-loading-failure`, `READY-4`], - [`failure-clears-rows`, `HEALTH-1`], - [`transient-is-fatal`, `HEALTH-1`], - [`delivery-error-is-fatal`, `HEALTH-1`], - [`never-recovers`, `HEALTH-2`], - [`transient-fails-loading`, `RETRY-1`], - [`splits-batch`, `PUB-1`], - [`drops-deletes`, `PUB-3`], + [`ready-before-delivery`, `READY-2 (status)`], + [`wraps-loading-failure`, `READY-4 (health error cause)`], + [`failure-clears-rows`, `HEALTH-1 (rows)`], + [`transient-is-fatal`, `HEALTH-1 (health error)`], + [`delivery-error-is-fatal`, `HEALTH-1 (health error)`], + [`never-recovers`, `HEALTH-2 (health failureReason)`], + [`transient-fails-loading`, `RETRY-1 (status)`], + [`splits-batch`, `PUB-1 (publication)`], + [`drops-deletes`, `PUB-3/FID-4 (rows)`], // It merges a later delivery, so it also applies one that must be // rejected for a duplicate key. - [`snapshot-merges`, /PUB-7|PUB-4/], - [`leaks-subscription`, `LIFE-2`], - [`restart-replays-rows`, `LIFE-5`], - [`swallows-prompt-failure`, `READY-4`], - [`ready-at-restart`, `LIFE-5`], - [`keeps-failed-source`, `LIFE-10`], - [`double-release`, `LIFE-10`], + [`snapshot-merges`, /PUB-7 \(rows\)|PUB-4 \(rows\)/], + [`leaks-subscription`, `LIFE-2/LIFE-10 (releases per subscription)`], + [`restart-replays-rows`, `LIFE-2/LIFE-5 (rows)`], + [`swallows-prompt-failure`, `READY-4 (status)`], + [`ready-at-restart`, `LIFE-2/LIFE-5 (status)`], + [`keeps-failed-source`, `LIFE-10 (open provider subscriptions)`], + [`double-release`, `LIFE-10 (releases per subscription)`], [`detached-rejection`, `HEALTH-5`], [`logs-failure`, `HEALTH-5`], // Its early fulfillment also answers a wait for a retired run's txid // before the new run is ready. - [`wait-off-by-one`, /EVID-1|EVID-2/], - [`retired-evidence-before-ready`, `EVID-2`], - [`forgets-retired-evidence`, `EVID-2`], - [`leaks-waits-after-fail`, `EVID-2`], - [`wait-ignores-timeout`, `EVID-3`], - [`disconnect-fails`, `RECON-1`], - [`renew-resubscribes`, `RENEW-1`], - [`drops-buffered`, `READY-10`], + [`wait-off-by-one`, /EVID-1 \(awaitTxId \d+\)|EVID-2 \(awaitTxId \d+\)/], + [`retired-evidence-before-ready`, `EVID-2 (awaitTxId 0)`], + [`forgets-retired-evidence`, /EVID-2 \(awaitTxId \d+\)/], + // The pattern's first alternative is only a prefix of the law, so this + // calibration also checks that a pattern names a law only when it matches + // all of it: a leftmost match would find `EVID-2` and name nothing. + [`leaks-waits-after-fail`, /EVID-2|EVID-2 \(awaitTxId \d+\)/], + [`wait-ignores-timeout`, /EVID-3 \(awaitTxId \d+\)/], + [`disconnect-fails`, `RECON-1 (health error)`], + [`renew-resubscribes`, `RENEW-1 (open provider subscriptions)`], + [`drops-buffered`, `READY-10/PUB-9 (rows)`], // It applies buffered batches before the rows, also before a delivery // that must write nothing. - [`buffer-before-rows`, /PUB-9|PUB-4/], - [`replays-per-batch`, `PUB-5`], + [`buffer-before-rows`, /READY-10\/PUB-9 \(rows\)|PUB-4 \(rows\)/], + [`replays-per-batch`, `PUB-5/PUB-9 (publication)`], [`ready-before-buffer`, `READY-10 (rows when ready)`], - [`shows-buffered`, `READY-10/PUB-9`], - [`merges-updates`, `FID-1`], + [`shows-buffered`, `READY-10/PUB-9 (rows)`], + [`merges-updates`, `FID-1 (fields)`], [`dates-as-strings`, `FID-1 (values)`], [`bigints-as-numbers`, `FID-1 (values)`], [`drops-nulls`, `FID-1 (values)`], [`loses-negative-zero`, `FID-1 (values)`], [`typed-booleans-as-numbers`, `FID-1 (values)`], - [`commits-malformed-prefix`, `PUB-4`], - [`ignores-malformed`, `PUB-4`], - [`relabels-inserts`, `PUB-2`], + [`commits-malformed-prefix`, `PUB-4 (rows)`], + [`ignores-malformed`, `PUB-4 (status)`], + [`relabels-inserts`, `PUB-2 (change events)`], ] oracleReplayTest(it).each(mutants)( `rejects the %s mutant with %s`, async (mutant, law) => { + oracleRoute(`calibration`) const result = await fc.check( - eagerLiveSourceProperty(referenceDriver(mutant)), - { - seed: 2118, - numRuns: runs, - }, + eagerLiveSourceProperty(referenceDriver(mutant), undefined, law), + { seed: 2118, numRuns: runs }, ) expect(result.failed).toBe(true) expect(result.errorInstance).toBeInstanceOf(ConformanceViolation) - expect((result.errorInstance as ConformanceViolation).law).toMatch(law) + expectNamedLaw((result.errorInstance as ConformanceViolation).law, law) }, campaignTimeoutMs, ) @@ -162,6 +181,7 @@ describe(`eager live-source conformance: prompt restart cut`, () => { oracleReplayTest(it)( `rejects a run that is ready at its prompt restart`, async () => { + oracleRoute(`calibration`) await expect( checkEagerLiveSourceHistory(referenceDriver(`ready-at-restart`), [ { kind: `fail` }, @@ -181,10 +201,9 @@ describe(`eager live-source conformance: per-change commits`, () => { `the per-change reference adapter conforms`, async () => { const perChange = referenceDriver(undefined, { perChange: true }) - await fc.assert(eagerLiveSourceProperty(perChange), { - seed: 2118, - numRuns: runs, - }) + const parameters = oracleFixedSeed(2118, { numRuns: runs }) + oracleRoute(oracleRouteOf(parameters)) + await fc.assert(eagerLiveSourceProperty(perChange), parameters) }, campaignTimeoutMs, ) @@ -192,14 +211,16 @@ describe(`eager live-source conformance: per-change commits`, () => { oracleReplayTest(it)( `rejects a batch committed out of order with PUB-1`, async () => { + oracleRoute(`calibration`) const reversed = referenceDriver(`reverses-batch`, { perChange: true }) - const result = await fc.check(eagerLiveSourceProperty(reversed), { - seed: 2118, - numRuns: runs, - }) + const result = await fc.check( + eagerLiveSourceProperty(reversed, undefined, `PUB-1 (publication)`), + { seed: 2118, numRuns: runs }, + ) expect(result.failed).toBe(true) - expect((result.errorInstance as ConformanceViolation).law).toContain( - `PUB-1`, + expectNamedLaw( + (result.errorInstance as ConformanceViolation).law, + `PUB-1 (publication)`, ) }, campaignTimeoutMs, @@ -222,35 +243,42 @@ describe(`eager live-source conformance: outage threshold, client values`, () => reachStatesFor(thresholdDriver), { minimum: 5 }, ) - await fc.assert(eagerLiveSourceProperty(thresholdDriver, reach), { - seed: 2118, - numRuns: runs, - }) + const campaignParameters = oracleFixedSeed(2118, { numRuns: runs }) + oracleRoute(oracleRouteOf(campaignParameters)) + await fc.assert( + eagerLiveSourceProperty(thresholdDriver, reach), + campaignParameters, + ) reach.assertReached() }, campaignTimeoutMs, ) - const outageMutants: Array<[ReferenceMutant, string]> = [ - [`stale-outage-value`, `HEALTH-`], - [`replaces-outage-value`, `RETRY-1`], - [`reports-outage-early`, `HEALTH-1`], - [`outage-count-survives-delivery`, `HEALTH-1`], - [`empty-response-keeps-count`, `HEALTH-1`], - [`empty-response-recovers`, `RETRY-1`], - [`rejected-delivery-fails`, `PUB-4`], - [`wraps-loading-failure`, `READY-4`], + const outageMutants: Array<[ReferenceMutant, string | RegExp]> = [ + [`stale-outage-value`, `HEALTH-1 (health failureReason)`], + [`replaces-outage-value`, `RETRY-1 (health failureReason)`], + [`reports-outage-early`, `HEALTH-1 (health failureReason)`], + [`outage-count-survives-delivery`, `HEALTH-1 (health failureReason)`], + [`empty-response-keeps-count`, `HEALTH-1 (health failureReason)`], + [`empty-response-recovers`, `RETRY-1 (health failureReason)`], + [`rejected-delivery-fails`, `PUB-4 (health error)`], + [`wraps-loading-failure`, `READY-4 (health error cause)`], ] oracleReplayTest(it).each(outageMutants)( `rejects the %s mutant with %s`, async (mutant, law) => { + oracleRoute(`calibration`) const result = await fc.check( - eagerLiveSourceProperty(referenceDriver(mutant, { outage })), + eagerLiveSourceProperty( + referenceDriver(mutant, { outage }), + undefined, + law, + ), { seed: 2118, numRuns: runs }, ) expect(result.failed).toBe(true) expect(result.errorInstance).toBeInstanceOf(ConformanceViolation) - expect((result.errorInstance as ConformanceViolation).law).toContain(law) + expectNamedLaw((result.errorInstance as ConformanceViolation).law, law) }, campaignTimeoutMs, ) @@ -493,6 +521,7 @@ describe(`eager live-source grammar controls`, () => { oracleReplayTest(it).each(witnesses)( `reconstructs %s`, async (_name, history) => { + oracleRoute(`fixed history`) expect(withinGrammar(history, withoutValues)).toBe(true) const { skipped } = await checkEagerLiveSourceHistory( withoutValues, @@ -505,6 +534,7 @@ describe(`eager live-source grammar controls`, () => { oracleReplayTest(it).each(valueWitnesses)( `reconstructs %s`, async (_name, history) => { + oracleRoute(`fixed history`) expect(withinGrammar(history, withoutFields)).toBe(true) const { skipped } = await checkEagerLiveSourceHistory( withoutFields, @@ -527,6 +557,7 @@ describe(`eager live-source grammar controls`, () => { oracleReplayTest(it)( `excludes a batch before the first ready without listen-before-fetch`, async () => { + oracleRoute(`fixed history`) const history = [{ kind: `changes` as const, changes: [] }] const { skipped } = await checkEagerLiveSourceHistory( lateListener, @@ -558,6 +589,7 @@ describe(`eager live-source grammar controls`, () => { ], ] oracleReplayTest(it).each(invalid)(`excludes %s`, async (_name, history) => { + oracleRoute(`fixed history`) const { skipped } = await checkEagerLiveSourceHistory( withoutValues, history, @@ -588,6 +620,8 @@ describe(`eager live-source grammar controls`, () => { const reach = createOracleReach(`${property}.ablation`, states, { replaying: false, }) + const runParameters = oracleFixedSeed(2118, { numRuns: 100 }) + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty( historyArb.map((history) => @@ -600,7 +634,7 @@ describe(`eager live-source grammar controls`, () => { }) }, ), - { seed: 2118, numRuns: 100 }, + runParameters, ) expect(reach.counts().get(lost)).toBe(0) }, @@ -619,6 +653,8 @@ describe(`eager live-source grammar controls`, () => { id: row.id, title: row.title, }) + const parameters = oracleFixedSeed(2118, { numRuns: 100 }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( historyArb.map((history) => @@ -644,7 +680,7 @@ describe(`eager live-source grammar controls`, () => { }) }, ), - { seed: 2118, numRuns: 100 }, + parameters, ) expect(reach.counts().get(`field-removed`)).toBe(0) }, @@ -663,6 +699,8 @@ describe(`eager live-source grammar controls`, () => { reachStatesFor(driver), { replaying: false }, ) + const parameters = oracleFixedSeed(2118, { numRuns: 100 }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( historyArb.map((history) => @@ -688,7 +726,7 @@ describe(`eager live-source grammar controls`, () => { }) }, ), - { seed: 2118, numRuns: 100 }, + parameters, ) expect(reach.counts().get(`value:date:change`)).toBe(0) expect(reach.counts().get(`value:date:load`)).toBeGreaterThan(0) @@ -706,6 +744,8 @@ describe(`eager live-source grammar controls`, () => { reachStatesFor(driver), { replaying: false }, ) + const parameters = oracleFixedSeed(2118, { numRuns: 100 }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( historyArb.map((history) => @@ -722,7 +762,7 @@ describe(`eager live-source grammar controls`, () => { }) }, ), - { seed: 2118, numRuns: 100 }, + parameters, ) expect(reach.counts().get(`read-missed-change`)).toBe(0) }, @@ -730,6 +770,9 @@ describe(`eager live-source grammar controls`, () => { ) }) +// Owns nothing: this package's src is the shared oracle itself, and the +// reference adapter under test is a fixture in tests/. The published adapters' +// receiving drivers own their adapters' entry points. declareOracle({ check: [checkEagerLiveSourceHistory, eagerLiveSourceProperty], model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], diff --git a/packages/db-collection-e2e/src/suites/joins-oracle.suite.ts b/packages/db-collection-e2e/src/suites/joins-oracle.suite.ts index f1cbe34f15..bacc949e4c 100644 --- a/packages/db-collection-e2e/src/suites/joins-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/joins-oracle.suite.ts @@ -7,7 +7,10 @@ import { randomUUID } from 'node:crypto' import { createLiveQueryCollection, eq, gt, isNull } from '@tanstack/db' import { describe, expect, it, vi } from 'vitest' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' +import { + declareOracle, + oracleRoute, +} from '../../../db/tests/oracle-declaration.js' import { waitFor, waitForQueryData } from '../utils/helpers' import type { Comment, @@ -361,6 +364,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { describe(`Joins Suite`, () => { describe(`Two-Collection Joins`, () => { it(`should join Users and Posts`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users const postsCollection = config.collections.onDemand.posts @@ -407,6 +411,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { }) it(`should join with predicates on both collections`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users const postsCollection = config.collections.onDemand.posts @@ -465,6 +470,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { `should join with one eager, one on-demand`, { timeout: 60000 }, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersEager = config.collections.eager.users const postsOnDemand = config.collections.onDemand.posts @@ -512,6 +518,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { ) it(`should join with ordering across collections`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users const postsCollection = config.collections.onDemand.posts @@ -583,6 +590,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { }) it(`should join with pagination`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users const postsCollection = config.collections.onDemand.posts @@ -632,6 +640,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { describe(`Three-Collection Joins`, () => { it(`should join Users + Posts + Comments`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const { users, posts, comments } = config.collections.onDemand @@ -684,6 +693,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { }) it(`should handle predicates on all three collections`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const { users, posts, comments } = config.collections.onDemand @@ -745,6 +755,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { `should handle mixed syncModes in 3-way join`, { timeout: 60000 }, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersEager = config.collections.eager.users const postsOnDemand = config.collections.onDemand.posts @@ -801,6 +812,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { describe(`Predicates in Joined Results`, () => { it(`should apply predicates to the selected joined fields`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const { users, posts } = config.collections.onDemand @@ -849,6 +861,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { }) it(`should exclude joined rows outside the user predicate`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const { users, posts } = config.collections.onDemand @@ -899,6 +912,7 @@ export function createJoinsTestSuite(getConfig: () => Promise) { describe(`Left Joins`, () => { it(`should handle left joins correctly`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const { users, posts } = config.collections.onDemand @@ -953,4 +967,10 @@ export function createJoinsTestSuite(getConfig: () => Promise) { }) } +// Owns no production entry. The suite judges the collection adapter that a +// consuming package runs it against (electric.e2e.test.ts, query.e2e.test.ts, +// trailbase.e2e.test.ts, or the persisted-collection conformance contract), +// but no consumer declares `owns` either, so the gap report counts none of +// this suite's kills. The coverage map records this as a follow-up under +// "Shared e2e suites". declareOracle({ check: [withJoinHistory], model: [createJoinOracle] }) diff --git a/packages/db-collection-e2e/src/suites/live-updates-oracle.suite.ts b/packages/db-collection-e2e/src/suites/live-updates-oracle.suite.ts index d44e7bba1c..6ae3b90b3d 100644 --- a/packages/db-collection-e2e/src/suites/live-updates-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/live-updates-oracle.suite.ts @@ -6,7 +6,10 @@ import { expectNotificationHistory, expectNotificationsStopped, } from '../utils/notification-laws-oracle' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' +import { + declareOracle, + oracleRoute, +} from '../../../db/tests/oracle-declaration.js' import { captureUserRows, userFixture, @@ -21,6 +24,7 @@ export function createLiveUpdatesTestSuite( describe('Live Updates Suite', () => { describe('Subscription Lifecycle', () => { it('should receive updates when subscribed', async () => { + oracleRoute(`fixed history`) const config = await getConfig() await withOwnedUsers(config, async (owned) => { const query = owned.track( @@ -128,6 +132,12 @@ export function createLiveUpdatesTestSuite( }) } +// Owns no production entry. The suite judges the collection adapter that a +// consuming package runs it against (electric.e2e.test.ts, query.e2e.test.ts, +// trailbase.e2e.test.ts, or the persisted-collection conformance contract), +// but no consumer declares `owns` either, so the gap report counts none of +// this suite's kills. The coverage map records this as a follow-up under +// "Shared e2e suites". declareOracle({ check: [expectNotificationHistory, expectNotificationsStopped], }) diff --git a/packages/db-collection-e2e/src/suites/moves-oracle.suite.ts b/packages/db-collection-e2e/src/suites/moves-oracle.suite.ts index 6609813c4e..5ebd880d7a 100644 --- a/packages/db-collection-e2e/src/suites/moves-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/moves-oracle.suite.ts @@ -9,7 +9,10 @@ import { randomUUID } from 'node:crypto' import { beforeAll, describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { electricCollectionOptions } from '@tanstack/electric-db-collection' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' +import { + declareOracle, + oracleRoute, +} from '../../../db/tests/oracle-declaration.js' import { waitFor } from '../utils/helpers' import { expectNotificationHistory } from '../utils/notification-laws-oracle' import type { E2ETestConfig, Post, User } from '../types' @@ -375,6 +378,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { function runTestsForSyncMode(syncMode: SyncMode) { describe(`${syncMode} mode`, () => { it(`Initial snapshot contains only posts from active users`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { if (!config.mutations) { throw new Error(`Mutations not configured`) @@ -491,6 +495,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) it(`Move-in: row becomes eligible for subquery`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { const collection = history.own( createPostsByActiveUsersCollection(syncMode), @@ -548,6 +553,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) it(`Move-out: row becomes ineligible for subquery`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { const collection = history.own( createPostsByActiveUsersCollection(syncMode), @@ -604,6 +610,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) it(`Move-out → move-in cycle`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { const collection = history.own( createPostsByActiveUsersCollection(syncMode), @@ -664,6 +671,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) it(`Title update while row stays within subquery`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { const collection = history.own( createPostsByActiveUsersCollection(syncMode), @@ -724,6 +732,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) it(`Database DELETE leads to row being removed from collection`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { const collection = history.own( createPostsByActiveUsersCollection(syncMode), @@ -781,6 +790,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) it(`Snapshot after move-out should not re-include removed rows`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { if (!config.mutations) { throw new Error(`Mutations not configured`) @@ -852,6 +862,7 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) it(`Multi-row transaction: some rows move in, some move out`, async () => { + oracleRoute(`fixed history`) await withMoveHistory(async (history) => { const collection = history.own( createPostsByActiveUsersCollection(syncMode), @@ -1008,4 +1019,9 @@ export function createMovesTestSuite(getConfig: () => Promise) { }) } +// Owns no production entry. The suite judges the collection adapter that its +// consuming package runs it against (electric.e2e.test.ts), but that consumer +// declares no `owns` either, so the gap report counts none of this suite's +// kills. The coverage map records this as a follow-up under +// "Shared e2e suites". declareOracle({ check: [assertOwnedPosts] }) diff --git a/packages/db-collection-e2e/src/suites/pagination-oracle.suite.ts b/packages/db-collection-e2e/src/suites/pagination-oracle.suite.ts index da96cee71b..3272f08fb7 100644 --- a/packages/db-collection-e2e/src/suites/pagination-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/pagination-oracle.suite.ts @@ -6,7 +6,10 @@ import { describe, expect, it, vi } from 'vitest' import { createLiveQueryCollection, eq } from '@tanstack/db' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' +import { + declareOracle, + oracleRoute, +} from '../../../db/tests/oracle-declaration.js' import { assertAllItemsMatch, assertCollectionSize, @@ -202,6 +205,7 @@ export function createPaginationTestSuite( describe(`Pagination Suite`, () => { describe(`OrderBy`, () => { it(`should sort ascending by single field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -230,6 +234,7 @@ export function createPaginationTestSuite( }) it(`should sort descending by single field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -258,6 +263,7 @@ export function createPaginationTestSuite( }) it(`should sort by string field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -288,6 +294,7 @@ export function createPaginationTestSuite( }) it(`should sort by date field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -316,6 +323,7 @@ export function createPaginationTestSuite( }) it(`should sort by multiple fields`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const postsCollection = config.collections.onDemand.posts @@ -355,6 +363,7 @@ export function createPaginationTestSuite( }) it(`should sort by multiple fields with chained orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -404,6 +413,7 @@ export function createPaginationTestSuite( describe(`Multi-Column OrderBy with Incremental Loading`, () => { it(`should correctly paginate with multi-column orderBy and limit`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -450,6 +460,7 @@ export function createPaginationTestSuite( }) it(`should load subsequent pages correctly with multi-column orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -519,6 +530,7 @@ export function createPaginationTestSuite( }) it(`should load distinct windows across multiple live queries with multi-column orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -587,6 +599,7 @@ export function createPaginationTestSuite( }) it(`should allow paging a second live query without affecting the first`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -652,6 +665,7 @@ export function createPaginationTestSuite( }) it(`should handle multi-column orderBy with duplicate values in first column`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -720,6 +734,7 @@ export function createPaginationTestSuite( }) it(`should handle multi-column orderBy with mixed directions`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const postsCollection = config.collections.onDemand.posts @@ -770,6 +785,7 @@ export function createPaginationTestSuite( }) it(`should handle three-column orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -822,6 +838,7 @@ export function createPaginationTestSuite( }) it(`should use setWindow to page through multi-column orderBy results`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -903,6 +920,7 @@ export function createPaginationTestSuite( }) it(`should use setWindow to move backwards with multi-column orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -966,6 +984,7 @@ export function createPaginationTestSuite( }) it(`should use setWindow with mixed direction multi-column orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const postsCollection = config.collections.onDemand.posts @@ -1029,6 +1048,7 @@ export function createPaginationTestSuite( }) it(`should handle setWindow across duplicate first-column values`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1113,6 +1133,7 @@ export function createPaginationTestSuite( }) it(`should expose successive windows when paging with multi-column orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1189,6 +1210,7 @@ export function createPaginationTestSuite( describe(`Limit`, () => { it(`should limit to specific number of records`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1217,6 +1239,7 @@ export function createPaginationTestSuite( }) it(`should handle limit=0`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1244,6 +1267,7 @@ export function createPaginationTestSuite( }) it(`should handle limit larger than dataset`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1272,6 +1296,7 @@ export function createPaginationTestSuite( }) it(`should combine limit with orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1303,6 +1328,7 @@ export function createPaginationTestSuite( describe(`Offset`, () => { it(`should skip records with offset`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1332,6 +1358,7 @@ export function createPaginationTestSuite( }) it(`should combine offset with limit (pagination)`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1361,6 +1388,7 @@ export function createPaginationTestSuite( }) it(`should handle offset beyond dataset`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1391,6 +1419,7 @@ export function createPaginationTestSuite( describe(`Complex Pagination Scenarios`, () => { it(`should paginate with predicates`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1422,6 +1451,7 @@ export function createPaginationTestSuite( }) it(`should handle pagination edge cases - last page with fewer records`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1453,6 +1483,7 @@ export function createPaginationTestSuite( }) it(`should handle single record pages`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1484,6 +1515,7 @@ export function createPaginationTestSuite( describe(`Requested Window Verification`, () => { it(`should return only the requested page`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const usersCollection = config.collections.onDemand.users @@ -1518,4 +1550,10 @@ export function createPaginationTestSuite( }) } +// Owns no production entry. The suite judges the collection adapter that a +// consuming package runs it against (electric.e2e.test.ts, query.e2e.test.ts, +// trailbase.e2e.test.ts, or the persisted-collection conformance contract), +// but no consumer declares `owns` either, so the gap report counts none of +// this suite's kills. The coverage map records this as a follow-up under +// "Shared e2e suites". declareOracle({ check: [withPaginationHistory], model: [createWindowOracle] }) diff --git a/packages/db-collection-e2e/src/suites/predicates-oracle.suite.ts b/packages/db-collection-e2e/src/suites/predicates-oracle.suite.ts index 51977a6d7b..07564a3a3d 100644 --- a/packages/db-collection-e2e/src/suites/predicates-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/predicates-oracle.suite.ts @@ -22,7 +22,10 @@ import { not, or, } from '@tanstack/db' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' +import { + declareOracle, + oracleRoute, +} from '../../../db/tests/oracle-declaration.js' import { assertAllItemsMatch, assertCollectionSize } from '../utils/assertions' import { waitForQueryData } from '../utils/helpers' import type { E2ETestConfig, Post, User } from '../types' @@ -172,6 +175,7 @@ export function createPredicatesTestSuite( describe(`Predicates Suite`, () => { describe(`Equality Operators`, () => { it(`should filter with eq() on string field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.name === `Alice 0`) @@ -193,6 +197,7 @@ export function createPredicatesTestSuite( }) it(`should filter with eq() on number field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.age === 25) @@ -211,6 +216,7 @@ export function createPredicatesTestSuite( }) it(`should filter with eq() on boolean field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.isActive === true) @@ -232,6 +238,7 @@ export function createPredicatesTestSuite( }) it(`should filter with eq() on UUID field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter( @@ -257,6 +264,7 @@ export function createPredicatesTestSuite( }) it(`should filter with isNull() for null values`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.email === null) @@ -280,6 +288,7 @@ export function createPredicatesTestSuite( describe(`Inequality Operators`, () => { it(`should filter with not(eq()) on string field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.name !== `Alice 0`) @@ -301,6 +310,7 @@ export function createPredicatesTestSuite( }) it(`should filter with not(isNull()) for non-null values`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.email !== null) @@ -324,6 +334,7 @@ export function createPredicatesTestSuite( describe(`Comparison Operators`, () => { it(`should filter with gt() on number field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.age > 50) @@ -342,6 +353,7 @@ export function createPredicatesTestSuite( }) it(`should filter with gte() on number field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.age >= 50) @@ -360,6 +372,7 @@ export function createPredicatesTestSuite( }) it(`should filter with lt() on number field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.age < 30) @@ -378,6 +391,7 @@ export function createPredicatesTestSuite( }) it(`should filter with lte() on number field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.age <= 30) @@ -396,6 +410,7 @@ export function createPredicatesTestSuite( }) it(`should filter with gt() on viewCount field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.posts.filter((p) => p.viewCount > 100) @@ -414,6 +429,7 @@ export function createPredicatesTestSuite( }) it(`should filter with eq() on BIGINT field using JavaScript BigInt`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.posts.filter( @@ -445,6 +461,7 @@ export function createPredicatesTestSuite( }) it(`should filter with gt() on BIGINT field using JavaScript BigInt`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.posts.filter( @@ -481,6 +498,7 @@ export function createPredicatesTestSuite( describe(`String Pattern Matching Operators`, () => { it(`should filter with like() operator (case-sensitive)`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.name.startsWith(`Alice`)) @@ -503,6 +521,7 @@ export function createPredicatesTestSuite( }) it(`should filter with ilike() operator (case-insensitive)`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -529,6 +548,7 @@ export function createPredicatesTestSuite( }) it(`should filter with like() with wildcard pattern (% at end)`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter( @@ -556,6 +576,7 @@ export function createPredicatesTestSuite( }) it(`should filter with like() with wildcard pattern (% in middle)`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter( @@ -583,6 +604,7 @@ export function createPredicatesTestSuite( }) it(`should filter with like() with wildcard pattern matching newline`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -607,6 +629,7 @@ export function createPredicatesTestSuite( }) it(`should filter with like() with lower() function`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -633,6 +656,7 @@ export function createPredicatesTestSuite( }) it(`should filter with ilike() with lower() function`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -659,6 +683,7 @@ export function createPredicatesTestSuite( }) it(`should filter with or() combining multiple like() conditions (search pattern)`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.posts.filter( @@ -700,6 +725,7 @@ export function createPredicatesTestSuite( }) it(`should filter with like() and orderBy`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = orderedAliceRows(fixture.users) @@ -734,6 +760,7 @@ export function createPredicatesTestSuite( }) it(`should filter with like() and limit`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = orderedAliceRows(fixture.users).slice(0, 5) @@ -765,6 +792,7 @@ export function createPredicatesTestSuite( }) it(`should handle like() with pattern matching no records`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -787,6 +815,7 @@ export function createPredicatesTestSuite( describe(`In Operator`, () => { it(`should filter with inArray() on string array`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -810,6 +839,7 @@ export function createPredicatesTestSuite( }) it(`should filter with inArray() on number array`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -831,6 +861,7 @@ export function createPredicatesTestSuite( }) it(`should filter with inArray() on UUID array`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -862,6 +893,7 @@ export function createPredicatesTestSuite( }) it(`should handle empty inArray()`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expectedIds = new Set() @@ -881,6 +913,7 @@ export function createPredicatesTestSuite( }) it(`should filter with inArray() on BIGINT array using JavaScript BigInt`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.posts.filter((p) => @@ -922,6 +955,7 @@ export function createPredicatesTestSuite( describe(`Null Operators`, () => { it(`should filter with isNull() on nullable field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.email === null) @@ -943,6 +977,7 @@ export function createPredicatesTestSuite( }) it(`should filter with not(isNull()) on nullable field`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.email !== null) @@ -964,6 +999,7 @@ export function createPredicatesTestSuite( }) it(`should filter with isNull() on deletedAt (soft delete pattern)`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.deletedAt === null) @@ -987,6 +1023,7 @@ export function createPredicatesTestSuite( describe(`Boolean Logic`, () => { it(`should combine predicates with and()`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter( @@ -1009,6 +1046,7 @@ export function createPredicatesTestSuite( }) it(`should combine predicates with or()`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter( @@ -1029,6 +1067,7 @@ export function createPredicatesTestSuite( }) it(`should handle complex nested logic`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter( @@ -1057,6 +1096,7 @@ export function createPredicatesTestSuite( }) it(`should handle NOT operator`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.isActive !== true) @@ -1077,6 +1117,7 @@ export function createPredicatesTestSuite( describe(`Predicate Result Verification`, () => { it(`should return only data matching the predicate`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.age === 25) @@ -1099,6 +1140,7 @@ export function createPredicatesTestSuite( }) it(`should exclude deleted records from filtered results`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.deletedAt === null) @@ -1122,6 +1164,7 @@ export function createPredicatesTestSuite( describe(`Multiple where() Calls`, () => { it(`should AND multiple where() calls together`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter( @@ -1145,6 +1188,7 @@ export function createPredicatesTestSuite( describe(`Edge Cases`, () => { it(`should handle query with no where clause on on-demand collection`, async () => { + oracleRoute(`fixed history`) // NOTE: Electric has a bug where empty subset requests don't load data // We work around this by injecting "true = true" when there's no where clause // This is always true so doesn't filter data, just tricks Electric into loading @@ -1170,6 +1214,7 @@ export function createPredicatesTestSuite( }) it(`should handle predicate matching no records`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => u.age === 999) @@ -1188,6 +1233,7 @@ export function createPredicatesTestSuite( }) it(`should handle complex AND with no matches`, async () => { + oracleRoute(`fixed history`) const config = await getConfig() const fixture = config.fixture() const expected = fixture.users.filter((u) => @@ -1213,4 +1259,10 @@ export function createPredicatesTestSuite( }) } +// Owns no production entry. The suite judges the collection adapter that a +// consuming package runs it against (electric.e2e.test.ts, query.e2e.test.ts, +// trailbase.e2e.test.ts, or the persisted-collection conformance contract), +// but no consumer declares `owns` either, so the gap report counts none of +// this suite's kills. The coverage map records this as a follow-up under +// "Shared e2e suites". declareOracle({ check: [assertPredicateRows], model: [orderedAliceRows] }) diff --git a/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts b/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts index 0b48ccae49..17ee462462 100644 --- a/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts +++ b/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts @@ -54,6 +54,12 @@ export function expectNotificationsStopped( expect(callbacks).toStrictEqual(archived) } +// Owns no production entry. These checks are shared: the db oracle +// tests/notification-laws-oracle.test.ts owns the change-message path it +// judges with them, but the shared e2e suites that also use them, and the +// packages that run those suites, declare no `owns`, so the gap report counts +// none of their kills. The coverage map records this as a follow-up under +// "Shared e2e suites". declareOracle({ check: [expectNotificationHistory, expectNotificationsStopped], }) diff --git a/packages/db-ivm/src/multiset.ts b/packages/db-ivm/src/multiset.ts index feb8904598..ca742b72f5 100644 --- a/packages/db-ivm/src/multiset.ts +++ b/packages/db-ivm/src/multiset.ts @@ -82,20 +82,33 @@ export class MultiSet { * changed. */ consolidate(): MultiSet { + // Development-only oracle probes read this. The published build removes + // this guard and its probes (scripts/strip-oracle-probes.mjs). A bundler + // that sets NODE_ENV and bundles these sources folds it to `false` and + // removes them; the typeof check keeps an unbundled module from throwing + // where `process` does not exist. + const probing = + typeof process === `undefined` + ? false + : process.env.NODE_ENV !== `production` const inner = this.#inner // Keyed multisets hold [string | number key, value] pairs. Their values are // compared by reference, which avoids hashing. Join tuples (values of // length 2) are unpacked so ['A', null] and [null, 'X'] stay distinct. - if ( - inner.every( - ([data]) => - Array.isArray(data) && - data.length === 2 && - (typeof data[0] === `string` || typeof data[0] === `number`), - ) - ) { - return consolidateKeyed(inner) - } + const keyed = inner.every( + ([data]) => + Array.isArray(data) && + data.length === 2 && + (typeof data[0] === `string` || typeof data[0] === `number`), + ) + // Development-only oracle probe; a production build removes it. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#keyed-identity`, keyed) + if (keyed) return consolidateKeyed(inner) // Unkeyed data of a single primitive type is its own identity, and the // result holds the Map key, so -0 becomes 0. Anything else is compared @@ -107,9 +120,36 @@ export class MultiSet { primitiveType ??= type return type !== primitiveType }) - return requireHash - ? consolidateBy(inner, hash) - : consolidateBy(inner, (data) => data, true) + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#primitive-identity`, !requireHash) + if (!requireHash) return consolidateBy(inner, (data) => data, true) + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#structural-identity`, true) + // The pairs-without-key scan runs only in development builds, where the + // probe's condition is evaluated. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `db-ivm#only-pairs-without-key`, + inner.every( + ([data]) => + Array.isArray(data) && + data.length === 2 && + !(typeof data[0] === `string` || typeof data[0] === `number`), + ), + ) + return consolidateBy(inner, hash) } extend(other: MultiSet | MultiSetArray): void { @@ -135,6 +175,11 @@ export class MultiSet { * looked up element by element, and leaves and tuples use separate maps. */ function consolidateKeyed(inner: MultiSetArray): MultiSet { + // Read by this function's development-only oracle probes, as above. + const probing = + typeof process === `undefined` + ? false + : process.env.NODE_ENV !== `production` const leaves = new Map>() const tuples = new Map>>() const entries: MultiSetArray = [] @@ -147,6 +192,22 @@ function consolidateKeyed(inner: MultiSetArray): MultiSet { let bySecond = byFirst.get(value[0]) if (!bySecond) byFirst.set(value[0], (bySecond = new Map())) entry = bySecond.get(value[1]) + // Development-only oracle probes; a production build removes them. + // They mark two declared states of the consolidation oracle: a merge on + // any path (`records merged`) and a merge of join tuples (`join tuples + // merged`), so a merge of join tuples enters both. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#records-merged`, entry !== undefined) + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#join-tuples-merged`, entry !== undefined) if (!entry) { bySecond.set(value[1], (entry = [data, 0])) entries.push(entry) @@ -155,6 +216,12 @@ function consolidateKeyed(inner: MultiSetArray): MultiSet { let byValue = leaves.get(key) if (!byValue) leaves.set(key, (byValue = new Map())) entry = byValue.get(value) + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#records-merged`, entry !== undefined) if (!entry) { byValue.set(value, (entry = [data, 0])) entries.push(entry) @@ -175,10 +242,22 @@ function consolidateBy( identityOf: (data: T) => unknown, keepIdentity = false, ): MultiSet { + // Read by this function's development-only oracle probes, as above. + const probing = + typeof process === `undefined` + ? false + : process.env.NODE_ENV !== `production` const consolidated = new Map() for (const [data, multiplicity] of inner) { const identity = identityOf(data) const entry = consolidated.get(identity) + // Development-only oracle probe; a production build removes it. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`db-ivm#records-merged`, entry !== undefined) if (entry) entry[1] += multiplicity else consolidated.set(identity, [data, multiplicity]) } diff --git a/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts b/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts index a963ea86ee..7c9b102c54 100644 --- a/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts @@ -12,8 +12,11 @@ import { distinct } from '../src/operators/distinct.js' import { map } from '../src/operators/map.js' import { createOracleReach } from '../../db/tests/oracle-reach.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -40,7 +43,12 @@ const policy = { clone: (value: DistinctValue) => ({ ...value }), } -declareOracle({ check: [assertUnaryIncrementalization, checkDistinctSnapshot] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/distinct.ts#distinct`], + check: [assertUnaryIncrementalization, checkDistinctSnapshot], +}) for (const mode of [`selected`, `tuple`] as const) { const values = distinctCollisionValues((value) => @@ -51,6 +59,11 @@ for (const mode of [`selected`, `tuple`] as const) { `preserves distinct collisions by ${mode} (${campaign})`, () => { const reach = createOracleReach(PROPERTY, DISTINCT_REACH_STATES) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2306, { numRuns: oracleRuns(100) }) + : oraclePropertyOptions(100, PROPERTY) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(distinctHistory, (worlds) => { reach.beginHistory() @@ -85,9 +98,7 @@ for (const mode of [`selected`, `tuple`] as const) { // Both successful delivery paths checked these input conditions. for (const state of distinctReachStates(worlds)) reach.reach(state) }), - campaign === `fixed` - ? { numRuns: oracleRuns(100), seed: 2306 } - : oraclePropertyOptions(100, PROPERTY), + parameters, ) reach.assertReached() process.stdout.write( @@ -98,6 +109,7 @@ for (const mode of [`selected`, `tuple`] as const) { } it(`rejects omitted, duplicate and wrong-member snapshots`, () => { + oracleRoute(`calibration`) const values = distinctCollisionValues(hash) const input: Weighted = values.map((value) => [value, 1]) for (const bad of [ diff --git a/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts b/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts index 4ede56e97b..b2a3ffb84f 100644 --- a/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts @@ -17,8 +17,11 @@ import { distinct } from '../src/operators/distinct.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' import { createOracleReach } from '../../db/tests/oracle-reach.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { @@ -37,7 +40,10 @@ import type { Weighted } from './incrementalization-law-oracle.js' const PROPERTY = `ivm.distinct-representatives` const family = distinctCollisionFamily(hash) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/operators/distinct.ts#distinct`], check: [checkDistinctRepresentatives], grammar: [distinctBucketHistory], }) @@ -105,6 +111,11 @@ for (const split of [false, true]) ...DISTINCT_BUCKET_REACH_STATES, `fresh equal selections`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2309, { numRuns: oracleRuns(100) }) + : oraclePropertyOptions(100, PROPERTY) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(distinctBucketHistory, (worlds) => { reach.beginHistory() @@ -112,9 +123,7 @@ for (const split of [false, true]) for (const state of distinctBucketReachStates(worlds)) reach.reach(state) }), - campaign === `fixed` - ? { numRuns: oracleRuns(100), seed: 2309 } - : oraclePropertyOptions(100, PROPERTY), + parameters, ) reach.assertReached() process.stdout.write( 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 ce6ba92d94..66fb6304c5 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 @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -123,7 +126,10 @@ function expectRetryHistory(history: History): void { expect(reads).toEqual(afterSuccess) } +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/hashing/hash.ts#hash`], check: [expectRetryHistory], model: [expectedFailedReads, expectedSuccessReads, expectedPlainCopy], }) @@ -132,42 +138,50 @@ describe(`getter failure does not publish partial structural hashes`, () => { it.each([0, 1, 2, 3])( `retries the same root after %s completed siblings`, (completed) => { + oracleRoute(`case`) expectRetryHistory({ completed, tail: 2, failures: 2, value: 10 }) }, ) it(`retries a first-getter failure without an untouched tail`, () => { + oracleRoute(`case`) expectRetryHistory({ completed: 0, tail: 0, failures: 1, value: -20 }) }) it(`retries three failures at the last getter`, () => { + oracleRoute(`case`) expectRetryHistory({ completed: 3, tail: 0, failures: 3, value: 20 }) }) oracleReplayTest(it)( `preserves failure causes and retry cuts (fixed seed 205206)`, () => { + const parameters = oracleFixedSeed(205206, { numRuns: oracleRuns(100) }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(retryHistoryArbitrary, (history) => expectRetryHistory(history), ), - { numRuns: oracleRuns(100), seed: 205206 }, + parameters, ) }, ) oracleReplayTest(it, retryProperty)( `preserves failure causes and retry cuts (random seed)`, () => { + const campaignParameters = oraclePropertyOptions(100, retryProperty) + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property(retryHistoryArbitrary, (history) => expectRetryHistory(history), ), - oraclePropertyOptions(100, retryProperty), + campaignParameters, ) }, ) it(`rejects cached-sibling and substituted-error observations`, () => { + oracleRoute(`calibration`) const error = new Error(`getter rejected`) expectFailureCut(error, [2, 2, 0], error, [2, 2, 0]) expect(() => expectFailureCut(error, [1, 2, 0], error, [2, 2, 0])).toThrow() 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 73fc6297a7..4a30b20e0c 100644 --- a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -21,7 +24,7 @@ import { declareOracle } from '../../db/tests/oracle-declaration.js' * keep rare topology classes present even when random generation misses them. * The generated topology claim is limited to plain objects with numeric * labels and array edges, at most six nodes and three edges per node. Cycle - * rejection through other containers is pinned by hash-graph.test.ts, an + * rejection through other containers is fixed by hash-graph.test.ts, an * ordinary test file. This file does not establish collision freedom or * complexity limits. */ @@ -102,7 +105,10 @@ function expectGraphHash( expect(hashValue(nodes[0])).toBe(hashValue(unfoldEdges(edges, 0))) } +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/hashing/hash.ts#hash`], check: [expectGraphHash], model: [isAcyclic, unfoldEdges], }) @@ -134,6 +140,7 @@ const graphWitnesses: Array<{ describe(`structural hash graph boundary`, () => { it.each(graphWitnesses)(`matches the $name witness`, ({ edges, acyclic }) => { + oracleRoute(`case`) // These expected categories are specified independently of Kahn's result. expect(isAcyclic(edges)).toBe(acyclic) expectGraphHash(edges) @@ -142,27 +149,34 @@ describe(`structural hash graph boundary`, () => { oracleReplayTest(it)( `matches reachable graph cycles and shared DAGs (fixed)`, () => { + const parameters = oracleFixedSeed(fixedSeed, { + numRuns: oracleRuns(300), + }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(graphArbitrary, (edges) => { expectGraphHash(edges) }), - { numRuns: oracleRuns(300), seed: fixedSeed }, + parameters, ) }, ) oracleReplayTest(it, graphProperty)( `matches reachable graph cycles and shared DAGs (random)`, () => { + const campaignParameters = oraclePropertyOptions(300, graphProperty) + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property(graphArbitrary, (edges) => { expectGraphHash(edges) }), - oraclePropertyOptions(300, graphProperty), + campaignParameters, ) }, ) it(`rejects cycle acceptance while accepting its acyclic near-neighbor`, () => { + oracleRoute(`calibration`) expect(() => expectGraphHash([[1], [0]], () => 0)).toThrow() expectGraphHash([[1], []], () => 0) expectGraphHash([[1], [0]]) @@ -170,6 +184,7 @@ describe(`structural hash graph boundary`, () => { }) it(`replays a cycle-acceptance mismatch at the same checkpoint`, () => { + oracleRoute(`calibration`) const property = fc.property(fc.integer({ min: 1, max: 3 }), (length) => { const edges = Array.from({ length: length + 1 }, (_, node) => [ node === length ? 0 : node + 1, @@ -193,6 +208,7 @@ describe(`structural hash graph boundary`, () => { }) it(`rejects false cycle rejection for unreachable cycles and shared DAGs`, () => { + oracleRoute(`calibration`) const rejectAll = (): number => { throw new TypeError(`Cannot hash cyclic structural values`) } @@ -210,6 +226,7 @@ describe(`structural hash graph boundary`, () => { }) it(`rejects unequal hashes of a shared DAG and its unfolding`, () => { + oracleRoute(`calibration`) let calls = 0 expect(() => expectGraphHash([[1, 2], [3], [3], []], () => ++calls), 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 6ce85f5817..dd0d61ea4d 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -7,8 +7,11 @@ import { registerOpaqueHash, } from '../src/hashing/hash.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -54,7 +57,7 @@ import { colliding } from './hash-collision-support.js' * equal, so every type marker has the same hash number. * * Authority: the method comments in `src/hashing/hash.ts`, and the behavior at - * `8283f2e8`, which this file pins before the hash dispatch refactor. + * `8283f2e8`, which this file fixes before the hash dispatch refactor. * * Limits: * - Arrays from another realm (`node:vm`) are outside the grammar. An ordinary @@ -781,7 +784,14 @@ function expectCyclicPair({ spec, changed, seeds }: CyclicPair): void { // The model is `identity` (and its encoding `canon`): equal identities must // share a verdict and hash. The grammar `pairArb` is calibrated below. +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/hashing/hash.ts#hash`, + `src/hashing/hash.ts#equalHashValues`, + `src/hashing/hash.ts#registerOpaqueHash`, + ], check: [expectPair, expectCyclicPair], model: [identity, canon], grammar: [pairArb], @@ -801,39 +811,51 @@ const it = oracleReplayTest(vitestIt) describe(`hash identity oracle`, () => { it(`matches the identity model across generated value pairs (${FIXED_SEED})`, () => { + const parameters = oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(300) }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(pairArb, fc.nat(), fc.nat(), (pair, left, right) => expectPair(pair, [left, right]), ), - { numRuns: oracleRuns(300), seed: FIXED_SEED }, + parameters, ) }) + const pairCampaign = oraclePropertyOptions(300, PAIR_IDENTITY) oracleReplayTest(fcTest, PAIR_IDENTITY).prop( [pairArb, fc.nat(), fc.nat()], - oraclePropertyOptions(300, PAIR_IDENTITY), + pairCampaign, )( `matches the identity model across generated value pairs (random)`, - (pair, left, right) => expectPair(pair, [left, right]), + (pair, left, right) => { + oracleRoute(oracleRouteOf(pairCampaign)) + expectPair(pair, [left, right]) + }, ) it(`matches constructed verdicts across generated cyclic values (${FIXED_SEED})`, () => { - fc.assert(fc.property(cyclicArb, expectCyclicPair), { + const campaignParameters = oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(300), - seed: FIXED_SEED, }) + oracleRoute(oracleRouteOf(campaignParameters)) + fc.assert(fc.property(cyclicArb, expectCyclicPair), campaignParameters) }) - oracleReplayTest(fcTest, CYCLIC_IDENTITY).prop( - [cyclicArb], - oraclePropertyOptions(300, CYCLIC_IDENTITY), - )( + const cyclicCampaign = oraclePropertyOptions(300, CYCLIC_IDENTITY) + oracleReplayTest(fcTest, CYCLIC_IDENTITY).prop([cyclicArb], cyclicCampaign)( `matches constructed verdicts across generated cyclic values (random)`, - expectCyclicPair, + (value: CyclicPair) => { + oracleRoute(oracleRouteOf(cyclicCampaign)) + expectCyclicPair(value) + }, ) // Positive execution witness: the fixed campaign reaches every value kind // and every mutation, with both verdicts. it(`reaches every kind, mutation, and verdict in the fixed campaign`, () => { - const sample = fc.sample(pairArb, { seed: FIXED_SEED, numRuns: 300 }) + oracleRoute(`fixed seed`) + const sample = fc.sample( + pairArb, + oracleFixedSeed(FIXED_SEED, { numRuns: 300 }), + ) const kinds = new Set() for (const { left } of sample) for (const [node] of nodes(left)) @@ -902,8 +924,8 @@ describe(`hash identity oracle`, () => { expect(merged).toBeGreaterThan(0) }) - // Pinned witnesses for rules that a wrong design would plausibly break. - const pinned: Array<[string, Spec, Spec]> = [ + // Fixed witnesses for rules that a wrong design would plausibly break. + const fixedWitnesses: Array<[string, Spec, Spec]> = [ [ `a hole differs from undefined`, { k: `array`, items: [null, num(1)] }, @@ -1027,7 +1049,8 @@ describe(`hash identity oracle`, () => { { k: `obj`, entries: [], syms: [[1, num(1)]] }, ], ] - it.each(pinned)(`%s`, (_name, left, right) => { + it.each(fixedWitnesses)(`%s`, (_name, left, right) => { + oracleRoute(`case`) for (const seeds of [ [1, 2], [3, 4], @@ -1036,6 +1059,7 @@ describe(`hash identity oracle`, () => { }) it(`does not accept a second copy because the first copy matched`, () => { + oracleRoute(`case`) const spec: Spec = { k: `twice`, child: { k: `obj`, entries: [[`a`, num(1)]], syms: [] }, @@ -1053,6 +1077,7 @@ describe(`hash identity oracle`, () => { }) it(`keeps distinct identities separate when their digests collide`, () => { + oracleRoute(`case`) const pair: Pair = { left: { k: `map`, entries: [] }, right: { k: `set`, items: [] }, @@ -1071,6 +1096,7 @@ describe(`hash identity oracle`, () => { }) it(`treats shared and copied subtrees, prototypes, and hidden properties as equal`, () => { + oracleRoute(`case`) const spec: Spec = { k: `twice`, child: { 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 feadf36119..3312944a41 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 @@ -2,9 +2,12 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -155,7 +158,10 @@ function expectMixedGraph( expect(hashValue(nodes[0])).toBe(hashValue(unfoldGraph(graph, 0, symbol))) } +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/hashing/hash.ts#hash`], check: [expectMixedGraph], model: [isAcyclic, unfoldGraph], }) @@ -188,6 +194,7 @@ function mixedGraphProperty( describe(`mixed-carrier structural graph laws`, () => { it.each(carriers)(`rejects a reachable cycle through %s`, (carrier) => { + oracleRoute(`calibration`) const graph: Graph = [{ label: 0, edges: [{ target: 0, carrier }] }] expect(isAcyclic(graph)).toBe(false) expectMixedGraph(graph) @@ -197,6 +204,7 @@ describe(`mixed-carrier structural graph laws`, () => { it.each(carriers)( `preserves distinct equal nodes and sharing through %s`, (carrier) => { + oracleRoute(`case`) const distinct: Graph = [ { label: 0, @@ -225,6 +233,7 @@ describe(`mixed-carrier structural graph laws`, () => { ) it(`distinguishes a mixed-container back edge from its disconnected near-neighbor`, () => { + oracleRoute(`case`) const cycle: Graph = [ { label: 0, edges: [{ carrier: `map-key`, target: 1 }] }, { label: 0, edges: [{ carrier: `set`, target: 2 }] }, @@ -240,6 +249,7 @@ describe(`mixed-carrier structural graph laws`, () => { }) it(`preserves a shared mixed-carrier DAG when unfolded`, () => { + oracleRoute(`case`) const diamond: Graph = [ { label: 0, @@ -271,6 +281,7 @@ describe(`mixed-carrier structural graph laws`, () => { ? () => 0 : hash const property = mixedGraphProperty(hashValue, () => checked++) + oracleRoute(oracleRouteOf(parameters)) fc.assert(property, parameters) // A green normal campaign must reach the graph check for every run. if (parameters.path === undefined) expect(checked).toBe(parameters.numRuns) @@ -279,17 +290,21 @@ describe(`mixed-carrier structural graph laws`, () => { oracleReplayTest(it)( `matches independent labels and mixed-carrier topology (fixed seed 205203)`, - () => expectMixedGraphCampaign({ numRuns: oracleRuns(150), seed: 205203 }), + () => { + expectMixedGraphCampaign( + oracleFixedSeed(205203, { numRuns: oracleRuns(150) }), + ) + }, ) oracleReplayTest(it, mixedGraphPropertyId)( `matches independent labels and mixed-carrier topology (random)`, - () => - expectMixedGraphCampaign( - oraclePropertyOptions(150, mixedGraphPropertyId), - ), + () => { + expectMixedGraphCampaign(oraclePropertyOptions(150, mixedGraphPropertyId)) + }, ) it(`replays a generated cycle-acceptance mismatch at the same law`, () => { + oracleRoute(`calibration`) const acceptsCycles = (): number => 0 const failed = fc.check(mixedGraphProperty(acceptsCycles), { seed: 205203, @@ -297,7 +312,7 @@ describe(`mixed-carrier structural graph laws`, () => { }) expect(failed.failed).toBe(true) expect(failed.counterexamplePath).not.toBeNull() - expect(failed.error).toContain(`reachable cycle`) + expect(String(failed.errorInstance)).toContain(`reachable cycle`) if (failed.counterexamplePath === null) throw new Error(`Missing mixed graph replay path`) const replay = fc.check(mixedGraphProperty(acceptsCycles), { @@ -308,10 +323,11 @@ describe(`mixed-carrier structural graph laws`, () => { }) expect(replay.failed).toBe(true) expect(replay.counterexample).toEqual(failed.counterexample) - expect(replay.error).toContain(`reachable cycle`) + expect(String(replay.errorInstance)).toContain(`reachable cycle`) }) it(`rejects unequal DAG and unfolding output and preserves the witness on replay`, () => { + oracleRoute(`calibration`) const property = fc.property(fc.integer({ min: 0, max: 2 }), (label) => { const graph: Graph = [ { diff --git a/packages/db-ivm/tests/hash-oracle.property.test.ts b/packages/db-ivm/tests/hash-oracle.property.test.ts index 0c6a505ad0..d81e5fb67a 100644 --- a/packages/db-ivm/tests/hash-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-oracle.property.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -75,7 +78,12 @@ function expectPermutedHashes( expectEqualHashes(original, reversed, hashValue) } // The key-order law: permuting the keys of an object leaves its hash unchanged. -declareOracle({ check: [expectPermutedHashes] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/hashing/hash.ts#hash`], + check: [expectPermutedHashes], +}) // The reusable value laws run with identical generators and checks in a // fixed campaign and a random campaign. A path replay of `ivm.hash-value` @@ -91,10 +99,14 @@ function hashProp]>(arbitraries: { return (name: string, check: (...values: Ts) => void): void => { const property = fc.property(...arbitraries, check) oracleReplayTest(it)(`${name} (fixed)`, () => { - fc.assert(property, { numRuns: oracleRuns(100), seed: 1657021 }) + const parameters = oracleFixedSeed(1657021, { numRuns: oracleRuns(100) }) + oracleRoute(oracleRouteOf(parameters)) + fc.assert(property, parameters) }) oracleReplayTest(it, valueProperty)(`${name} (random)`, () => { - fc.assert(property, oraclePropertyOptions(100, valueProperty)) + const campaignParameters = oraclePropertyOptions(100, valueProperty) + oracleRoute(oracleRouteOf(campaignParameters)) + fc.assert(property, campaignParameters) }) } } @@ -328,6 +340,7 @@ describe(`hash property-based tests`, () => { describe(`law checker calibration`, () => { it(`rejects an insertion-order-sensitive result but accepts equal structures`, () => { + oracleRoute(`calibration`) const entries: Array<[string, number]> = [ [`key:left`, 1], [`key:right`, 2], @@ -348,6 +361,7 @@ describe(`hash property-based tests`, () => { }) it(`shrinks and replays an injected equality-law failure`, () => { + oracleRoute(`calibration`) const property = fc.property(arbitraryNonIndexEntries, (entries) => { let calls = 0 expectPermutedHashes(entries, () => ++calls) diff --git a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts index 65f98b70c8..6e234fd8dd 100644 --- a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts @@ -16,8 +16,11 @@ import { StreamBuilder } from '../src/d2.js' import { DifferenceStreamWriter, LinearUnaryOperator } from '../src/graph.js' import { MultiSet } from '../src/multiset.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -193,7 +196,7 @@ const generatedCampaigns = [ { name: `fixed`, test: oracleReplayTest(it), - parameters: { numRuns: RUNS, seed: FIXED_SEED }, + parameters: oracleFixedSeed(FIXED_SEED, { numRuns: RUNS }), }, { name: `random`, @@ -543,7 +546,21 @@ const orderedHistory: fc.Arbitrary>> = fc // The grammar of legal ordered histories. Its calibration test checks that the // generated steps reach row promotions, which the ordered laws need. -declareOracle({ grammar: [orderedHistory] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/operators/map.ts#map`, + `src/operators/filter.ts#filter`, + `src/operators/consolidate.ts#consolidate`, + `src/operators/reduce.ts#reduce`, + `src/operators/groupBy.ts#groupBy`, + `src/operators/join.ts#join`, + `src/operators/orderBy.ts#orderByWithFractionalIndex`, + `src/operators/groupedOrderBy.ts#groupedOrderByWithFractionalIndex`, + ], + grammar: [orderedHistory], +}) function firstThree(input: Weighted): Weighted { return keyedIdentity(input) @@ -574,7 +591,9 @@ describe(`DBSP incrementalization laws`, () => { campaign.test( `checks consolidate, reduce, and groupBy against full recomputation (${campaign.name} campaign)`, async () => { + // The gap report counts a fixed campaign's verdict as reproducible. const reach = new CampaignReach() + oracleRoute(oracleRouteOf(campaign.parameters)) await fc.assert( fc.asyncProperty( fc.array(weightedWorld, { minLength: 2, maxLength: 7 }), @@ -684,6 +703,7 @@ describe(`DBSP incrementalization laws`, () => { `checks simultaneous binary join deltas and split delivery (${campaign.name} campaign)`, async () => { const reach = new CampaignReach() + oracleRoute(oracleRouteOf(campaign.parameters)) await fc.assert( fc.asyncProperty( fc.array(fc.tuple(weightedWorld, weightedWorld), { @@ -794,6 +814,7 @@ describe(`DBSP incrementalization laws`, () => { `checks global ordering and window membership (${campaign.name} campaign)`, async () => { const reach = new CampaignReach() + oracleRoute(oracleRouteOf(campaign.parameters)) await fc.assert( fc.asyncProperty(orderedHistory, async (worlds) => { await setImmediate() @@ -843,6 +864,7 @@ describe(`DBSP incrementalization laws`, () => { // can promote, so worlds too small to fill it would lose this law while // every comparison still passed. it(`generates ordered histories that promote retained rows`, () => { + oracleRoute(`fixed seed`) const promotions = { global: 0, perParity: 0 } const promoted = ( window: (rows: Weighted) => Weighted, @@ -855,10 +877,10 @@ describe(`DBSP incrementalization laws`, () => { ([row]) => held.has(String(row)) && !before.has(String(row)), ) } - for (const [worlds] of fc.sample(fc.tuple(orderedHistory), { - seed: FIXED_SEED, - numRuns: RUNS, - })) { + for (const [worlds] of fc.sample( + fc.tuple(orderedHistory), + oracleFixedSeed(FIXED_SEED, { numRuns: RUNS }), + )) { for (const [index, next] of worlds.slice(1).entries()) { const previous = worlds[index]! if (promoted(firstThree, previous, next)) promotions.global++ @@ -872,6 +894,7 @@ describe(`DBSP incrementalization laws`, () => { }) it(`replays empty, duplicate, replacement, cancellation, and presence-flip cells`, () => { + oracleRoute(`case`) const unaryReach = assertUnaryIncrementalization({ name: `named unary cells`, initial: [], @@ -1065,6 +1088,8 @@ describe(`DBSP incrementalization laws`, () => { }) it(`rejects invalid negative and duplicate-key input states`, () => { + // A checker calibration: a failure here is the framework's, not a kill. + oracleRoute(`calibration`) expect(() => assertUnaryIncrementalization({ name: `negative input state excluded`, @@ -1095,6 +1120,7 @@ describe(`DBSP incrementalization laws`, () => { }) it(`rejects omitted, sign-flipped, wrong-member, wrong-window, wrong-limit, missing-promotion, and truthiness-filtered output`, () => { + oracleRoute(`calibration`) expect(() => assertUnaryIncrementalization({ name: `omitted output fault`, 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 856e22cc87..8530d6298a 100644 --- a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts +++ b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts @@ -44,7 +44,7 @@ * compared through the same prefix identity. * * Calibration: comparing prefixes with `===` kept a cancelled `NaN`-prefixed - * value, or threw `Mismatching prefixes`, and fails the pinned histories and + * value, or threw `Mismatching prefixes`, and fails the fixed histories and * both campaigns. * * Known omissions: `Index` joins, compaction, presence tracking, and structural @@ -55,8 +55,11 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { Index } from '../src/indexes.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -165,7 +168,7 @@ const add = (value: Value, multiplicity: number): Addition => ({ }) // Each history cancels or merges `NaN`-prefixed values in a different layout. -const pinnedHistories: ReadonlyArray<{ +const fixedHistories: ReadonlyArray<{ name: string additions: Array }> = [ @@ -234,7 +237,15 @@ function expectRefinement( // The refinement law is declared with its model, the multiset of values per // key, and its grammar, the generated addition histories. +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/indexes.ts#Index.addValue`, + `src/indexes.ts#Index.get`, + `src/indexes.ts#Index.has`, + `src/indexes.ts#Index.getIterator`, + ], check: [expectRefinement], model: [expectedIndex], grammar: [historyArbitrary], @@ -249,8 +260,9 @@ declareOracle({ const INDEX_REFINEMENT_PROPERTY = `ivm.index-refinement` describe(`Index refinement oracle`, () => { - for (const { name, additions } of pinnedHistories) { + for (const { name, additions } of fixedHistories) { oracleReplayTest(it)(`matches the multiset model when ${name}`, () => { + oracleRoute(`fixed history`) expectRefinement(additions) expectRefinement(additions, `prefixIdentity`) }) @@ -259,12 +271,14 @@ describe(`Index refinement oracle`, () => { oracleReplayTest(it)( `matches the multiset model across generated additions (20260930)`, () => { + const parameters = oracleFixedSeed(20260930, { numRuns: oracleRuns(300) }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(historyArbitrary, (additions) => { expectRefinement(additions) expectRefinement(additions, `prefixIdentity`) }), - { numRuns: oracleRuns(300), seed: 20260930 }, + parameters, ) }, ) @@ -272,12 +286,17 @@ describe(`Index refinement oracle`, () => { oracleReplayTest(it, INDEX_REFINEMENT_PROPERTY)( `matches the multiset model across generated additions (random or replayed)`, () => { + const campaignParameters = oraclePropertyOptions( + 300, + INDEX_REFINEMENT_PROPERTY, + ) + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property(historyArbitrary, (additions) => { expectRefinement(additions) expectRefinement(additions, `prefixIdentity`) }), - oraclePropertyOptions(300, INDEX_REFINEMENT_PROPERTY), + campaignParameters, ) }, ) @@ -285,7 +304,11 @@ describe(`Index refinement oracle`, () => { // Positive execution witness: generated histories reach a NaN prefix that // cancels after another value joined its key. it(`reaches cancelled NaN-prefixed values beside other values`, () => { - const sample = fc.sample(historyArbitrary, { seed: 20260930, numRuns: 300 }) + oracleRoute(`fixed seed`) + const sample = fc.sample( + historyArbitrary, + oracleFixedSeed(20260930, { numRuns: 300 }), + ) const reached = sample.filter((additions) => additions.some( (addition, step) => 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 9555e9bf6e..3ab298bf40 100644 --- a/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts +++ b/packages/db-ivm/tests/multiset-consolidate-oracle.property.test.ts @@ -1,16 +1,23 @@ -import { describe, expect, it as vitestIt } from 'vitest' -import { fc, test as fcTest } from '@fast-check/vitest' +import fc from 'fast-check' import { MultiSet } from '../src/multiset.js' import { - oraclePropertyOptions, - oracleReplayTest, - oracleRuns, -} from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' + differs, + oracleProperty, + pass, + violation, +} from '../../db/tests/oracle-property.js' +import { declareOracle } from '../../db/tests/oracle-declaration' import { contentsOf } from './multiset-support.js' +import type { Observation } from '../../db/tests/oracle-property.js' import type { Data } from './multiset-support.js' import type { MultiSetArray } from '../src/multiset.js' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/multiset.ts#MultiSet.consolidate`], +}) + /** * # Which records does `MultiSet.consolidate()` merge? * @@ -50,6 +57,36 @@ import type { MultiSetArray } from '../src/multiset.js' * - Key order is covered only by one fixed pair of keys that tie under locale * collation (a precomposed and a decomposed Å), inserted in both orders in * structural histories; equal objects must merge whatever the order. + * + * ## States, and what reach counts + * + * A state is a fact that production enters: taking an identity rule's path, or + * joining a record to an identity that already holds one. Production marks each + * state with a development-only probe (`db-ivm#...`). The model predicts each + * state from the history alone (`predict`). A state counts as reached only + * where the probe entered it (was called with a true condition) in the same + * history that the model predicted, which is the law `reach agreement`. + * + * - `keyed identity`: rule 1 governs the whole multiset. The rule holds + * vacuously for the empty multiset, since no record fails it. Nothing is + * identified there, so the empty output is the same on every path. + * - `primitive identity` and `structural identity`: rules 2 and 3. + * - `records merged`: an identity holds two or more input records. A merge + * counts even when the summed multiplicity is zero, because the records + * joined one identity before the zero sum dropped it. The earlier label + * compared the output size with the number of distinct record values, which + * a `Set` counts with SameValueZero: `-0` and `0` are one value there, so + * that merge went unlabeled. Rule 2 names one identity for them as well, so + * the merge is real and this state counts it. + * - `join tuples merged`: two join tuples with the same key and elements merge. + * - `only pairs without a key`: every record is a pair whose first element is + * no key, so the multiset takes rule 3 although each record is a pair. A + * multiset of pairs with some keys is not in this state: it takes rule 1 when + * every pair is keyed, and rule 3 otherwise. + * + * Grammar modes are generator dimensions. They are not reach: a history in a + * mode may enter none of these states, and no state is counted by its mode. + * Each state has a constructed history, so every campaign enters every state. */ type Spec = @@ -107,11 +144,36 @@ const KEYED_KEYS: ReadonlyArray = [ // --------------------------------------------------------------------------- // Model. It does not use production identity helpers or `hash`. +/** The identity rule (1, 2, or 3 in the law) that governs a whole multiset. */ +type Rule = `keyed` | `primitive` | `structural` +type Identity = { + /** The retained record, normalized as rule 2 says. */ + first: Data + /** The input position of the identity's first record. */ + firstIndex: number + sum: number + /** How many input records the identity holds; two or more is a merge. */ + members: number + /** Whether its records are join tuples, which only the keyed rule has. */ + tuple: boolean +} type Expected = { - groups: Array<{ first: Data; sum: number }> + rule: Rule + /** Every identity, including one whose sum is zero. */ + identities: Array + /** The identities that the output owes: a nonzero sum. */ + groups: Array sameIdentity: (left: Data, right: Data) => boolean } +// A keyed record is a `[key, value]` pair with a string or number key. +const isKeyedRecord = (data: Data): boolean => + Array.isArray(data) && + data.length === 2 && + (typeof data[0] === `string` || typeof data[0] === `number`) +const isJoinTuple = (value: Data): boolean => + Array.isArray(value) && value.length === 2 + function expectedConsolidation(records: MultiSetArray): Expected { // SameValueZero compares primitive values; strict equality also preserves // reference identity for objects, functions, and symbols. @@ -149,33 +211,47 @@ function expectedConsolidation(records: MultiSetArray): Expected { return [typeof value, value === undefined ? null : value] } - const isKeyed = - records.length > 0 && - records.every( - ([data]) => - Array.isArray(data) && - data.length === 2 && - (typeof data[0] === `string` || typeof data[0] === `number`), - ) - const onePrimitiveType = [`string`, `number`].some((type) => - records.every(([data]) => typeof data === type), - ) - const sameIdentity: Expected['sameIdentity'] = isKeyed - ? sameKeyed - : onePrimitiveType - ? sameLeaf - : (left, right) => - JSON.stringify(structure(left)) === JSON.stringify(structure(right)) + // The rule the law gives the whole multiset. "Every record" holds vacuously + // for the empty multiset, so it is keyed there; no record is identified, so + // nothing observable depends on that choice. + const rule: Rule = records.every(([data]) => isKeyedRecord(data)) + ? `keyed` + : [`string`, `number`].some((type) => + records.every(([data]) => typeof data === type), + ) + ? `primitive` + : `structural` + const sameIdentity: Expected['sameIdentity'] = + rule === `keyed` + ? sameKeyed + : rule === `primitive` + ? sameLeaf + : (left, right) => + JSON.stringify(structure(left)) === JSON.stringify(structure(right)) const retained = (data: Data) => - onePrimitiveType && Object.is(data, -0) ? 0 : data + rule === `primitive` && Object.is(data, -0) ? 0 : data - const groups: Expected['groups'] = [] - for (const [data, multiplicity] of records) { - const group = groups.find(({ first }) => sameIdentity(first, data)) - if (group) group.sum += multiplicity - else groups.push({ first: retained(data), sum: multiplicity }) + const identities: Array = [] + for (const [index, [data, multiplicity]] of records.entries()) { + const identity = identities.find(({ first }) => sameIdentity(first, data)) + if (identity) { + identity.sum += multiplicity + identity.members += 1 + } else + identities.push({ + first: retained(data), + firstIndex: index, + sum: multiplicity, + members: 1, + tuple: rule === `keyed` && isJoinTuple((data as [Data, Data])[1]), + }) + } + return { + rule, + identities, + groups: identities.filter(({ sum }) => sum !== 0), + sameIdentity, } - return { groups: groups.filter(({ sum }) => sum !== 0), sameIdentity } } // --------------------------------------------------------------------------- @@ -198,6 +274,22 @@ function specArbFor(maxPrimitiveIndex: number): fc.Arbitrary { } const keyedSpecArb = specArbFor(KEYED_PRIMITIVES.length - 1) +// Join tuples over a small domain, so that equal tuples recur within one +// history. The general keyed grammar draws its leaves from a wide domain, and +// two tuples there rarely share both elements. +const smallLeafArb: fc.Arbitrary = fc.oneof( + fc.record({ kind: fc.constant(`prim` as const), i: fc.nat(2) }), + fc.record({ kind: fc.constant(`obj` as const), i: fc.nat(1) }), +) +const tupleSpecArb: fc.Arbitrary = fc.record({ + kind: fc.constant(`tuple` as const), + a: smallLeafArb, + b: smallLeafArb, +}) +// The first element of a pair: indices 0 to 2 are values no key can be, and +// indices 3 and 4 are keys. `unkeyedPairs` draws from 0 to 2, and `mixedPairs` +// draws from all five, so a multiset of pairs can hold keyed and unkeyed pairs. +const PAIR_FIRSTS: ReadonlyArray = [true, false, null, 0, `a`] // The unkeyed fallback checks structural hashing on its original value domain. const fallbackSpecArb = specArbFor(9) const multiplicityArb = fc.integer({ min: -2, max: 2 }) @@ -212,15 +304,28 @@ type StructuralStep = // Equal objects whose two keys tie under locale collation, inserted in // either order. They must merge. | { fresh: `collationTie`; reversed: boolean } + // A pair whose first element is no key: a multiset of only these is not + // keyed, so equal object contents must merge structurally. + | { fresh: `unkeyedPair`; v: number } type Mode = - `keyed` | `keyedCollision` | `numbers` | `strings` | `structural` | `fallback` + | `keyed` + | `keyedCollision` + | `numbers` + | `strings` + | `structural` + | `fallback` + | `tuples` + | `unkeyedPairs` + | `mixedPairs` + | `mixedPrimitives` // `keyedCollision` selects grammar witnesses; it exercises the same keyed // production path as `keyed`. `caseName` labels a replay, not product state. +// A `keyedCollision` history has one step: `[[name, opposed], scale]`, the +// witness's name and how its multiplicities are signed and scaled. type History = { mode: Mode steps: Array<[unknown, number]> - caseName?: string } // Each pair has distinct keyed identities under the documented rule. They @@ -283,16 +388,73 @@ const collisionCases: Array<{ name: string; records: MultiSetArray }> = [ ], }, ] -const collisionHistoryArb: fc.Arbitrary = fc - .tuple(fc.constantFrom(...collisionCases), fc.constantFrom(-2, -1, 1, 2)) - .map(([collision, multiplicity]) => ({ - mode: `keyedCollision`, - caseName: collision.name, - steps: collision.records.map(([data], index) => [ - data, - index === 0 ? multiplicity : -multiplicity, - ]), - })) +// More keyed witnesses, which the generated `keyed` steps cannot build: +// reference-identity values that must still merge with themselves, a signed +// zero key, non-finite keys, and a delimiter key beside an object tuple. +const delimiterFirst = { v: 1 } +const delimiterSecond = { v: 2 } +collisionCases.push( + { + name: `a direct object and an object tuple stay apart across a delimiter key`, + records: [ + [[`a|ref_1`, delimiterSecond], 1], + [[`a`, [delimiterFirst, delimiterSecond]], -1], + ], + }, + { + name: `the same symbol value still merges`, + records: [ + [[`k`, firstSymbol], 1], + [[`k`, firstSymbol], 2], + ], + }, + { + name: `the same function value still merges`, + records: [ + [[`k`, firstFunction], 1], + [[`k`, firstFunction], 2], + ], + }, + { + name: `signed zero keys still merge`, + records: [ + [[-0, `v`], 1], + [[0, `v`], 2], + ], + }, + { + name: `non-finite numeric keys stay separate`, + records: [ + [[NaN, `v`], 1], + [[Infinity, `v`], 2], + [[-Infinity, `v`], 3], + ], + }, + { + name: `NaN values still merge`, + records: [ + [[`k`, NaN], 1], + [[`k`, NaN], 2], + ], + }, +) +// A witness is named by its case name, and its multiplicities are scaled, +// so the history is plain data and a fixed witness is a member of the +// language. `opposed` gives the first record the scale and every later one +// its negation, so distinct identities would cancel if wrongly merged; +// otherwise each record keeps its own multiplicity times the scale. +const collisionHistoryArb: fc.Arbitrary = fc.record({ + mode: fc.constant(`keyedCollision` as const), + steps: fc.tuple( + fc.tuple( + fc.tuple( + fc.constantFrom(...collisionCases.map(({ name }) => name)), + fc.boolean(), + ), + fc.constantFrom(-2, -1, 1, 2), + ), + ), +}) const historyArb: fc.Arbitrary = fc.oneof( collisionHistoryArb, @@ -303,20 +465,54 @@ const historyArb: fc.Arbitrary = fc.oneof( fc.tuple(fc.nat(KEYED_KEYS.length - 1), keyedSpecArb), multiplicityArb, ), - { maxLength: 24 }, + { maxLength: 24, size: `medium` }, ), }), + fc.record({ + mode: fc.constant(`tuples` as const), + steps: fc.array( + fc.tuple(fc.tuple(fc.nat(3), tupleSpecArb), multiplicityArb), + { maxLength: 24, size: `medium` }, + ), + }), + fc.record({ + mode: fc.constant(`unkeyedPairs` as const), + steps: fc.array(fc.tuple(fc.tuple(fc.nat(2), fc.nat(1)), multiplicityArb), { + maxLength: 24, + size: `medium`, + }), + }), + fc.record({ + mode: fc.constant(`mixedPairs` as const), + steps: fc.array(fc.tuple(fc.tuple(fc.nat(4), fc.nat(1)), multiplicityArb), { + maxLength: 24, + size: `medium`, + }), + }), fc.record({ mode: fc.constant(`numbers` as const), steps: fc.array( fc.tuple(fc.constantFrom(0, -0, 1, 2, 3, NaN), multiplicityArb), - { maxLength: 24 }, + { maxLength: 24, size: `medium` }, + ), + }), + // Strings and numbers together: rule 2 needs one primitive type, so this + // multiset is structural, and `1` and `"1"` stay apart. + fc.record({ + mode: fc.constant(`mixedPrimitives` as const), + steps: fc.array( + fc.tuple(fc.constantFrom(0, 1, `1`, `p`), multiplicityArb), + { + maxLength: 24, + size: `medium`, + }, ), }), fc.record({ mode: fc.constant(`strings` as const), steps: fc.array(fc.tuple(fc.constantFrom(`p`, `q`, `r`), multiplicityArb), { maxLength: 24, + size: `medium`, }), }), fc.record({ @@ -325,18 +521,23 @@ const historyArb: fc.Arbitrary = fc.oneof( fc.tuple( fc.oneof( fc.constantFrom(1, `1`, true, null), - fc.nat(2).map((v): StructuralStep => ({ fresh: `object`, v })), - fc - .tuple(fc.nat(1), fc.nat(1)) - .map((pair): StructuralStep => ({ fresh: `array`, pair })), - fc.boolean().map((reversed): StructuralStep => ({ - fresh: `collationTie`, - reversed, - })), + fc.record({ fresh: fc.constant(`object` as const), v: fc.nat(2) }), + fc.record({ + fresh: fc.constant(`array` as const), + pair: fc.tuple(fc.nat(1), fc.nat(1)), + }), + fc.record({ + fresh: fc.constant(`collationTie` as const), + reversed: fc.boolean(), + }), + fc.record({ + fresh: fc.constant(`unkeyedPair` as const), + v: fc.nat(1), + }), ), multiplicityArb, ), - { minLength: 1, maxLength: 24 }, + { minLength: 1, maxLength: 24, size: `medium` }, ), }), fc.record({ @@ -346,11 +547,23 @@ const historyArb: fc.Arbitrary = fc.oneof( fc.tuple(fc.nat(FALLBACK_KEYS.length - 1), fallbackSpecArb), multiplicityArb, ), - { minLength: 1, maxLength: 23 }, + { minLength: 1, maxLength: 23, size: `medium` }, ), }), ) +// A constructed history keeps its witness steps, then the generated steps of +// the same mode that fit the grammar's length bound, so a campaign explores +// what follows the state rather than one fixed visit to it. +function continuing( + mode: Mode, + witness: History['steps'], + generated: History, +): History { + const tail = generated.mode === mode ? generated.steps : [] + return { mode, steps: [...witness, ...tail].slice(0, 24) } +} + // Build the records of one history with fresh object references. function buildRecords(history: History): MultiSetArray { const objects = [{ v: 1 }, { v: 1 }, { v: 2 }] @@ -363,11 +576,16 @@ function buildRecords(history: History): MultiSetArray { ? [spec.i, spec.i, spec.i] : fromLeaf(spec) const records: MultiSetArray = history.steps.map(([step, m]) => { - if (history.mode === `keyedCollision`) { - const [key, value] = step as [string | number, Data] - return [[key, Array.isArray(value) ? [...value] : value], m] + if (history.mode === `keyedCollision`) return [step, m] + if (history.mode === `unkeyedPairs` || history.mode === `mixedPairs`) { + const [first, v] = step as [number, number] + return [[PAIR_FIRSTS[first]!, { v }], m] } - if (history.mode === `keyed` || history.mode === `fallback`) { + if ( + history.mode === `keyed` || + history.mode === `fallback` || + history.mode === `tuples` + ) { const [keyIndex, spec] = step as [number, Spec] return [ [ @@ -377,7 +595,11 @@ function buildRecords(history: History): MultiSetArray { m, ] } - if (history.mode === `numbers` || history.mode === `strings`) + if ( + history.mode === `numbers` || + history.mode === `strings` || + history.mode === `mixedPrimitives` + ) return [step, m] const value = step as StructuralStep if (value !== null && typeof value === `object`) { @@ -389,268 +611,553 @@ function buildRecords(history: History): MultiSetArray { if (value.reversed) entries.reverse() return [Object.fromEntries(entries), m] } + if (value.fresh === `unkeyedPair`) return [[true, { v: value.v }], m] return [value.fresh === `object` ? { v: value.v } : [...value.pair], m] } return [value, m] }) // One non-keyed record makes the whole multiset unkeyed. if (history.mode === `fallback`) records.push([{ v: 1 }, 1]) + if (history.mode === `keyedCollision`) { + const [[[name, opposed], scale]] = history.steps as [ + [[string, boolean], number], + ] + // Fresh objects per history, with each original object mapped to one + // clone, so references a witness shares stay shared within a history. + const clones = new Map() + const fresh = (value: Data): Data => + Array.isArray(value) + ? value.map(fresh) + : value !== null && typeof value === `object` + ? (clones.get(value) ?? clones.set(value, { ...value }).get(value)!) + : value + return collisionCases + .find((entry) => entry.name === name)! + .records.map(([data, m], index) => [ + fresh(data), + opposed ? (index === 0 ? scale : -scale) : m * scale, + ]) + } return records } // --------------------------------------------------------------------------- -// Production driver and refinement check. - -function expectRecordsConsolidated(records: MultiSetArray): void { - const inputSnapshot = records.map(([data, m]) => [data, m] as const) - const contentIds = new Map() - const inputContents = contentsOf(records, contentIds) - const { groups: expected, sameIdentity } = expectedConsolidation(records) - - const actual = new MultiSet(records).consolidate().getInner() - - // Consolidation does not change the input. - expect(records.length).toBe(inputSnapshot.length) - records.forEach(([data, m], index) => { - expect(data).toBe(inputSnapshot[index]![0]) - expect(m).toBe(inputSnapshot[index]![1]) - }) - expect(contentsOf(records, contentIds), `input record contents`).toBe( - inputContents, - ) - - // Record every output entry. A duplicate identity or a zero multiplicity - // stays visible. - const seen = new Set() - for (const [data, multiplicity] of actual) { - const index = expected.findIndex(({ first }) => sameIdentity(first, data)) - expect( - index, - `unexpected output entry ${seen.size}`, - ).toBeGreaterThanOrEqual(0) - expect(seen.has(index), `duplicate identity ${index}`).toBe(false) - seen.add(index) - const group = expected[index]! - expect(multiplicity, `multiplicity of group ${index}`).toBe(group.sum) - expect(data, `retained record of group ${index}`).toBe(group.first) - } - expect( - expected.flatMap((_, index) => (seen.has(index) ? [] : [index])), - `missing identities`, - ).toEqual([]) -} +// Production driver +// --------------------------------------------------------------------------- +// +// The driver consolidates the plan's records and records plain data. An +// output object, array, function, or symbol is named by the first input +// record that is the same reference (or as fresh when none is); a primitive, +// which has no identity, by its type and text, with -0 kept distinct. It also records the +// input's references, multiplicities, and contents before and after. -function expectConsolidation(history: History): void { - expectRecordsConsolidated(buildRecords(history)) +type Name = { input: number } | { type: string; text: string } +type Consolidated = { + output: Array<{ data: Name; multiplicity: number }> + inputBefore: string + inputAfter: string + /** Positions whose data or multiplicity changed (`Object.is`). */ + inputChanged: Array } -declareOracle({ - check: [expectRecordsConsolidated], - model: [expectedConsolidation], - grammar: [historyArb], +const isReference = (data: Data) => + (data !== null && typeof data === `object`) || + typeof data === `function` || + typeof data === `symbol` +const primitiveName = (data: Data): Name => ({ + type: typeof data, + text: Object.is(data, -0) ? `-0` : String(data), }) -// --------------------------------------------------------------------------- -// Campaigns. The fixed and random campaigns run the same property, grammar, -// check, and budget. A path replay of `ivm.multiset-consolidate` runs its -// recorded history once, and every other test stands aside. A seed alone -// reseeds the random campaign. +function nameOf(records: MultiSetArray, data: Data): Name { + if (!isReference(data)) return primitiveName(data) + const index = records.findIndex(([input]) => input === data) + return index >= 0 + ? { input: index } + : { type: `fresh ${typeof data}`, text: `` } +} -const FIXED_SEED = 2026929 -const CONSOLIDATE = `ivm.multiset-consolidate` +/** The records one history builds, and the mode they were built in. */ +type Plan = { mode: Mode; records: MultiSetArray } -const it = oracleReplayTest(vitestIt) +function driveConsolidation( + { records }: Plan, + observe: (checkpoint: string, value: unknown) => void, +) { + const before = records.map(([data, m]) => [data, m] as const) + const ids = new Map() + const inputBefore = contentsOf(records, ids) + const output = new MultiSet(records).consolidate().getInner() + observe(`consolidated`, { + output: output.map(([data, multiplicity]) => ({ + data: nameOf(records, data), + multiplicity, + })), + inputBefore, + inputAfter: contentsOf(records, ids), + inputChanged: before.flatMap(([data, m], index) => + Object.is(records[index]?.[0], data) && Object.is(records[index]?.[1], m) + ? [] + : [index], + ), + } satisfies Consolidated) +} -describe(`MultiSet consolidation oracle`, () => { - it.each(collisionCases)(`$name`, ({ records }) => - expectRecordsConsolidated(records), - ) +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- +// +// Each output record must name an owed identity once, with the identity's +// summed multiplicity (`merge`), and be the identity's first input record, +// normalized as rule 2 says (`retained`); every owed identity must appear +// (`merge`); the input must be unchanged (`input`). - it(`keeps a direct object and an object tuple apart across a delimiter key`, () => { - const first = { v: 1 } - const second = { v: 2 } - expectRecordsConsolidated([ - [[`a|ref_1`, second], 1], - [[`a`, [first, second]], -1], - ]) - }) +function owedName(first: Data, firstIndex: number): Name { + return isReference(first) ? { input: firstIndex } : primitiveName(first) +} - it.each<[string, MultiSetArray]>([ - [ - `the same symbol value still merges`, - [ - [[`k`, firstSymbol], 1], - [[`k`, firstSymbol], 2], - ], - ], - [ - `the same function value still merges`, - [ - [[`k`, firstFunction], 1], - [[`k`, firstFunction], 2], - ], - ], - [ - `signed zero keys still merge`, - [ - [[-0, `v`], 1], - [[0, `v`], 2], - ], - ], - [ - `non-finite numeric keys stay separate`, - [ - [[NaN, `v`], 1], - [[Infinity, `v`], 2], - [[-Infinity, `v`], 3], - ], - ], - [ - `NaN values still merge`, - [ - [[`k`, NaN], 1], - [[`k`, NaN], 2], - ], - ], - ])(`%s`, (_name, records) => expectRecordsConsolidated(records)) +function decode(records: MultiSetArray, name: Name): Data | undefined { + if (`input` in name) return records[name.input]?.[0] + if (name.type === `number`) return Number(name.text) + if (name.type === `string`) return name.text + if (name.type === `boolean`) return name.text === `true` + if (name.type === `bigint`) return BigInt(name.text) + if (name.type === `undefined`) return undefined + return name.text === `null` ? null : name.text +} - it(`matches the identity model across generated multisets (${FIXED_SEED})`, () => { - fc.assert( - fc.property(historyArb, (history) => expectConsolidation(history)), - { numRuns: oracleRuns(300), seed: FIXED_SEED }, +function checkConsolidation( + { records }: Plan, + trace: ReadonlyArray, + { groups, sameIdentity }: Expected, +) { + const seen = trace.find(({ checkpoint }) => checkpoint === `consolidated`) + ?.value as Consolidated | undefined + if (!seen) + return violation(`consolidated`, `the driver recorded nothing`, `trace`) + const inputWrong = + differs( + `consolidated`, + `input`, + seen.inputChanged, + [], + `input records changed`, + ) ?? + differs( + `consolidated`, + `input`, + seen.inputAfter, + seen.inputBefore, + `input record contents changed`, ) - }) - oracleReplayTest(fcTest, CONSOLIDATE).prop( - [historyArb], - oraclePropertyOptions(300, CONSOLIDATE), - )( - `matches the identity model across generated multisets (random)`, - expectConsolidation, + if (inputWrong) return inputWrong + const matched = new Set() + for (const [position, { data, multiplicity }] of seen.output.entries()) { + const value = decode(records, data) + const index = groups.findIndex(({ first }) => sameIdentity(first, value)) + if (index < 0) + return violation( + `consolidated`, + `output entry ${position} ${JSON.stringify(data)} is no owed identity`, + `merge`, + ) + if (matched.has(index)) + return violation( + `consolidated`, + `output entry ${position} repeats identity ${index}`, + `merge`, + ) + matched.add(index) + const group = groups[index]! + const wrong = + differs( + `consolidated`, + `merge`, + multiplicity, + group.sum, + `identity ${index} has another multiplicity`, + ) ?? + differs( + `consolidated`, + `retained`, + data, + owedName(group.first, group.firstIndex), + `identity ${index} kept another record`, + ) + if (wrong) return wrong + } + const missing = groups.flatMap((_, index) => + matched.has(index) ? [] : [index], ) + return ( + differs( + `consolidated`, + `merge`, + missing, + [], + `owed identities are missing`, + ) ?? pass + ) +} - // Positive execution witness: the fixed campaign reaches every mode, and many - // histories have fewer result identities than distinct input records. That - // happens only when records merge or cancel. - it(`reaches every mode and merges records in the fixed campaign`, () => { - const sample = fc.sample(historyArb, { seed: FIXED_SEED, numRuns: 300 }) - const modes = new Set(sample.map((history) => history.mode)) - expect([...modes].sort()).toEqual( - [ - `fallback`, - `keyed`, - `keyedCollision`, - `numbers`, - `strings`, - `structural`, - ].sort(), - ) - const withMerges = sample.filter((history) => { - const records = buildRecords(history) - return ( - expectedConsolidation(records).groups.length < - new Set(records.map(([data]) => data)).size - ) - }) - expect(withMerges.length).toBeGreaterThan(30) - }) +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +// The declared states. Each is marked by a production probe and predicted by +// the model, so reach comes from production (see the header's States section). +const keyedIdentity = `keyed identity` +const primitiveIdentity = `primitive identity` +const structuralIdentity = `structural identity` +const recordsMerged = `records merged` +const joinTuplesMerged = `join tuples merged` +const pairsWithoutKey = `only pairs without a key` +const witness = (name: string): History => ({ + mode: `keyedCollision`, + steps: [[[name, false], 1]], +}) - // Pinned witnesses for each identity rule. The generated campaigns can reach - // these, but pinning them makes each rule visible and stable. - it.each<[string, History]>([ - [ - `keyed object values merge by reference, not by contents`, +oracleProperty({ + id: `ivm.multiset-consolidate`, + law: { + statement: `MultiSet.consolidate() keeps one record per identity, the first in input order (with -0 written as 0 for unkeyed numbers), with the summed multiplicity; an identity whose sum is zero is absent; identity is keyed, single-primitive, or structural by the shape of the whole multiset; the input is unchanged.`, + authority: `the contract comment on MultiSet.consolidate() in packages/db-ivm/src/multiset.ts (b5d92ceb)`, + limits: `Output order is not part of the law. The keyed grammar includes the type, reference, and delimiter collisions of #1948 and non-finite numeric keys; MultiSet directly, not the query shapes that produce these records. Structural identity uses a 32-bit hash; the small domain makes a collision unlikely, not impossible. Key order only for one collation tie.`, + owner: `Structural values and ordered primitives`, + }, + histories: { + arbitrary: historyArb, + states: [ + { name: keyedIdentity, probe: `db-ivm#keyed-identity` }, + { name: primitiveIdentity, probe: `db-ivm#primitive-identity` }, + { name: structuralIdentity, probe: `db-ivm#structural-identity` }, + { name: recordsMerged, probe: `db-ivm#records-merged` }, + { name: joinTuplesMerged, probe: `db-ivm#join-tuples-merged` }, + { name: pairsWithoutKey, probe: `db-ivm#only-pairs-without-key` }, + ], + // Each state's constructed history enters it in every campaign. The + // constructed history keeps the state's witness, then generated steps. + constructed: [ { - mode: `keyed`, - steps: [ - [[0, { kind: `obj`, i: 0 }], 1], - [[0, { kind: `obj`, i: 1 }], -1], - [[0, { kind: `obj`, i: 0 }], 1], - ], + name: `a keyed multiset`, + enters: [keyedIdentity], + build: (generated) => + continuing(`keyed`, [[[0, { kind: `obj`, i: 0 }], 1]], generated), + }, + { + name: `a single-number multiset`, + enters: [primitiveIdentity], + build: (generated) => continuing(`numbers`, [[1, 1]], generated), + }, + { + name: `a multiset of one fresh object`, + enters: [structuralIdentity], + build: (generated) => + continuing(`structural`, [[{ fresh: `object`, v: 0 }, 1]], generated), }, - ], - [ - `fresh join tuples with the same elements merge`, { - mode: `keyed`, - steps: [ - [ + name: `two equal strings`, + enters: [primitiveIdentity, recordsMerged], + build: (generated) => + continuing( + `strings`, [ - 3, - { - kind: `tuple`, - a: { kind: `prim`, i: 3 }, - b: { kind: `prim`, i: 5 }, - }, + [`p`, 1], + [`p`, 1], ], - 1, - ], - [ + generated, + ), + }, + { + name: `two fresh join tuples with the same elements`, + enters: [keyedIdentity, recordsMerged, joinTuplesMerged], + build: (generated) => { + const tuple = { + kind: `tuple` as const, + a: { kind: `prim` as const, i: 0 }, + b: { kind: `prim` as const, i: 1 }, + } + return continuing( + `tuples`, [ - 3, - { - kind: `tuple`, - a: { kind: `prim`, i: 3 }, - b: { kind: `prim`, i: 5 }, - }, + [[0, tuple], 1], + [[0, tuple], 1], ], - -1, + generated, + ) + }, + }, + { + name: `two equal pairs without a key`, + enters: [structuralIdentity, pairsWithoutKey, recordsMerged], + build: (generated) => + continuing( + `unkeyedPairs`, + [ + [[0, 0], 1], + [[0, 0], 1], + ], + generated, + ), + }, + ], + runs: 300, + fixedSeeds: [2026929], + // Fixed witnesses for each identity rule. Generated histories can reach + // many of these, but fixing them makes each rule visible and stable. + fixed: [ + ...collisionCases.map(({ name }) => ({ + name, + enters: [keyedIdentity], + history: witness(name), + })), + { + name: `keyed object values merge by reference, not by contents`, + enters: [keyedIdentity, recordsMerged], + history: { + mode: `keyed`, + steps: [ + [[0, { kind: `obj`, i: 0 }], 1], + [[0, { kind: `obj`, i: 1 }], -1], + [[0, { kind: `obj`, i: 0 }], 1], ], - [ + }, + }, + { + name: `fresh join tuples with the same elements merge`, + enters: [keyedIdentity, recordsMerged, joinTuplesMerged], + history: { + mode: `keyed`, + steps: [ + [ + [ + 3, + { + kind: `tuple`, + a: { kind: `prim`, i: 3 }, + b: { kind: `prim`, i: 5 }, + }, + ], + 1, + ], [ - 3, - { - kind: `tuple`, - a: { kind: `obj`, i: 2 }, - b: { kind: `prim`, i: 0 }, - }, + [ + 3, + { + kind: `tuple`, + a: { kind: `prim`, i: 3 }, + b: { kind: `prim`, i: 5 }, + }, + ], + -1, + ], + [ + [ + 3, + { + kind: `tuple`, + a: { kind: `obj`, i: 2 }, + b: { kind: `prim`, i: 0 }, + }, + ], + 2, ], - 2, ], - ], + }, }, - ], - [ - `arrays of other lengths stay reference values`, { - mode: `keyed`, - steps: [ - [[1, { kind: `triple`, i: 0 }], 1], - [[1, { kind: `triple`, i: 0 }], 1], - ], + name: `arrays of other lengths stay reference values`, + enters: [keyedIdentity], + history: { + mode: `keyed`, + steps: [ + [[1, { kind: `triple`, i: 0 }], 1], + [[1, { kind: `triple`, i: 0 }], 1], + ], + }, }, - ], - [ - `one non-keyed record makes equal contents merge structurally`, { - mode: `fallback`, - steps: [ - [[0, { kind: `obj`, i: 0 }], 1], - [[0, { kind: `obj`, i: 1 }], 1], - ], + name: `one non-keyed record makes equal contents merge structurally`, + enters: [structuralIdentity, recordsMerged], + history: { + mode: `fallback`, + steps: [ + [[0, { kind: `obj`, i: 0 }], 1], + [[0, { kind: `obj`, i: 1 }], 1], + ], + }, }, - ], - [ - `single-number data merges -0 with 0 and returns 0`, { - mode: `numbers`, - steps: [ - [-0, 1], - [0, 1], - [NaN, 1], - [NaN, 1], - ], + name: `single-number data merges -0 with 0 and returns 0`, + // -0 and 0 are one identity under rule 2, so the second record merges + // into the first; NaN merges with NaN in the same way. + enters: [primitiveIdentity, recordsMerged], + history: { + mode: `numbers`, + steps: [ + [-0, 1], + [0, 1], + [NaN, 1], + [NaN, 1], + ], + }, }, - ], - [ - `unkeyed structural data keeps 1 and "1" apart`, { - mode: `structural`, - steps: [ - [1, 1], - [`1`, -1], - [1, 1], - ], + name: `single-string data merges equal strings`, + enters: [primitiveIdentity, recordsMerged], + history: { + mode: `strings`, + steps: [ + [`p`, 1], + [`q`, 2], + [`p`, -1], + ], + }, + }, + { + name: `pairs without a key merge equal object contents structurally`, + enters: [structuralIdentity, pairsWithoutKey, recordsMerged], + history: { + mode: `structural`, + steps: [ + [{ fresh: `unkeyedPair`, v: 0 }, 1], + [{ fresh: `unkeyedPair`, v: 0 }, 1], + [{ fresh: `unkeyedPair`, v: 1 }, -1], + ], + }, + }, + { + name: `unkeyed structural data keeps 1 and "1" apart`, + enters: [structuralIdentity], + history: { + mode: `structural`, + steps: [ + [1, 1], + [`1`, -1], + [1, 1], + ], + }, }, ], - ])(`%s`, (_name, history) => expectConsolidation(history)) + }, + resolve: (history): Plan => ({ + mode: history.mode, + records: buildRecords(history), + }), + model: ({ records }) => expectedConsolidation(records), + // Each state, from the history alone. The model's identity grouping supplies + // the rule and the member counts; no production code or trace is read. + predict: ({ records }) => { + const expected = expectedConsolidation(records) + return { + [keyedIdentity]: expected.rule === `keyed`, + [primitiveIdentity]: expected.rule === `primitive`, + [structuralIdentity]: expected.rule === `structural`, + [recordsMerged]: expected.identities.some(({ members }) => members > 1), + [joinTuplesMerged]: expected.identities.some( + ({ members, tuple }) => tuple && members > 1, + ), + [pairsWithoutKey]: + records.length > 0 && + records.every( + ([data]) => + Array.isArray(data) && data.length === 2 && !isKeyedRecord(data), + ), + } + }, + driver: driveConsolidation, + check: checkConsolidation, + calibrations: [ + { + name: `one identity appears twice`, + law: `merge`, + corrupt: (trace) => + trace.map((entry) => { + const value = entry.value as Consolidated + return entry.checkpoint === `consolidated` && value.output.length > 0 + ? { + ...entry, + value: { + ...value, + output: [...value.output, value.output[0]!], + }, + } + : entry + }), + }, + { + name: `a multiplicity is not summed`, + law: `merge`, + corrupt: (trace) => + trace.map((entry) => { + const value = entry.value as Consolidated + return entry.checkpoint === `consolidated` && value.output.length > 0 + ? { + ...entry, + value: { + ...value, + output: value.output.map((record, index) => + index === 0 + ? { ...record, multiplicity: record.multiplicity + 1 } + : record, + ), + }, + } + : entry + }), + }, + { + // A later record of the same identity, under another reference, named + // in place of the first. + name: `a later record of an identity is retained`, + law: `retained`, + corrupt: (trace, { records }) => { + const value = trace.find( + ({ checkpoint }) => checkpoint === `consolidated`, + )?.value as Consolidated | undefined + if (!value) return undefined + const { sameIdentity } = expectedConsolidation(records) + for (const [position, { data }] of value.output.entries()) { + if (!(`input` in data)) continue + const first = records[data.input]![0] + const later = records.findIndex( + ([candidate], index) => + index > data.input && + candidate !== first && + sameIdentity(first, candidate), + ) + if (later < 0) continue + return trace.map((entry) => + entry.checkpoint === `consolidated` + ? { + ...entry, + value: { + ...value, + output: value.output.map((record, index) => + index === position + ? { ...record, data: { input: later } } + : record, + ), + }, + } + : entry, + ) + } + return undefined + }, + }, + { + name: `consolidation changes the input`, + law: `input`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `consolidated` + ? { + ...entry, + value: { ...(entry.value as Consolidated), inputChanged: [0] }, + } + : entry, + ), + }, + ], }) 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 eff82ae784..b27e96cfdd 100644 --- a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts +++ b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { fc } from '@fast-check/vitest' -import { oracleRuns } from '../../db/tests/oracle-config.js' +import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config.js' +import { runOracleCampaign } from '../../db/tests/oracle-campaign.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' /** @@ -91,17 +92,31 @@ function expectDigestsMatchReference( ): void { expect(digests(Stream, chunks)).toEqual(digests(Model, chunks)) } -declareOracle({ check: [expectDigestsMatchReference] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/hashing/murmur.ts#MurmurHashStream.update`, + `src/hashing/murmur.ts#MurmurHashStream.digest`, + ], + check: [expectDigestsMatchReference], +}) describe(`MurmurHashStream`, () => { - it(`digests every chunk sequence as the byte-at-a-time stream does`, () => { - fc.assert( - fc.property(fc.array(chunk, { minLength: 1, maxLength: 8 }), (chunks) => { - expect(digests(Current, chunks)).toEqual(digests(Reference, chunks)) - }), - { numRuns: oracleRuns(2000) }, - ) - }) + // A fixed-seed campaign, then a random one, each registering its route, + // so a failure on the fixed seed is a reproducible verdict. + it(`digests every chunk sequence as the byte-at-a-time stream does`, () => + runOracleCampaign(`IVM_MURMUR_STREAM`, [], (_reach, parameters) => { + fc.assert( + fc.property( + fc.array(chunk, { minLength: 1, maxLength: 8 }), + (chunks) => { + expect(digests(Current, chunks)).toEqual(digests(Reference, chunks)) + }, + ), + { numRuns: oracleRuns(2000), ...parameters }, + ) + })) it.each([ [`two bytes, after a one-code-unit string`, [`x`, 1.5, -0, `yz`]], @@ -110,6 +125,7 @@ describe(`MurmurHashStream`, () => { ] as Array<[string, Array]>)( `digests the words that follow a block offset of %s`, (_, chunks) => { + oracleRoute(`case`) expectDigestsMatchReference(Current, Reference, chunks) }, ) diff --git a/packages/db-ivm/tests/operators/groupBy.test.ts b/packages/db-ivm/tests/operators/groupBy.test.ts index acc2aaa916..594395f931 100644 --- a/packages/db-ivm/tests/operators/groupBy.test.ts +++ b/packages/db-ivm/tests/operators/groupBy.test.ts @@ -839,7 +839,7 @@ describe(`Operators`, () => { }) test(`min and max can return the deleted instance of a hash-equal value`, () => { - // Current behavior, pinned so a db-ivm change alerts the compiler owner. + // Current behavior, fixed so a db-ivm change alerts the compiler owner. // The delete's retraction is the last record the shared entry gets, so // its instance, the deleted value, is the one that comes back, in either // insertion order. 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 b75d7346fd..e29055d291 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,7 +4,14 @@ 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' +import { oracleRoute, oracleRuns } from '../../../db/tests/oracle-config.js' +import { declareOracle } from '../../../db/tests/oracle-declaration' + +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/reduce.ts#reduce`], +}) /** * # Does the reduce fast path emit what the general path emits? @@ -128,6 +135,8 @@ const stepArbitrary: fc.Arbitrary = fc.record({ describe(`reduce fast path`, () => { it(`emits the general path's entries after every run`, () => { + // No seed: a random campaign. + oracleRoute(`random seed`) fc.assert( fc.property( fc.array(fc.array(stepArbitrary, { minLength: 1, maxLength: 4 }), { diff --git a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts index 6bb58b09f4..8bb45138d4 100644 --- a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts @@ -7,9 +7,25 @@ import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalI import { groupedTopKWithFractionalIndex } from '../../src/operators/groupedTopKWithFractionalIndex.js' import { join, map, output } from '../../src/operators/index.js' import { assertOnlyKeysAffected } from '../test-utils.js' -import { oracleRuns } from '../../../db/tests/oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../../db/tests/oracle-config.js' +import { declareOracle } from '../../../db/tests/oracle-declaration' import { TopKMessageTracker, TopKRelation } from './topk-relation-oracle.js' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/operators/topKWithFractionalIndex.ts#topKWithFractionalIndex`, + `src/operators/join.ts#join`, + ], +}) + // Helper function to check if indices are in lexicographic order function checkLexicographicOrder(results: Array) { // Extract values and their indices @@ -73,94 +89,28 @@ const groupedWindow: typeof topKWithFractionalIndex = (comparator, options) => }) describe.each([ - { name: `array`, topK: topKWithFractionalIndex }, - { name: `grouped array`, topK: groupedWindow }, -])('Generated fractional $name windows', ({ topK }) => { - it.each([409036, undefined])( - `preserves cyclic payloads through replacements and fresh transients (seed %s)`, - (seed) => { - fc.assert( - fc.property( - fc.boolean(), - fc.integer({ min: 0, max: 3 }), - fc.array( - fc.record({ - key: fc.integer({ min: 1, max: 3 }), - value: fc.option(fc.integer({ min: 0, max: 5 }), { nil: null }), - additionFirst: fc.boolean(), - }), - { minLength: 1, maxLength: 20 }, - ), - (useClass, limit, steps) => { - class Row { - payload: unknown - constructor( - public id: number, - public value: string, - ) { - this.payload = this - } - } - const make = (id: number, value: string) => { - if (useClass) return new Row(id, value) - const row = { id, value, payload: undefined as unknown } - row.payload = row - return row - } - const graph = new D2() - const input = graph.newInput<[number, Row]>() - const rows = new TopKRelation() - const values = new Map([[1, `0`]]) - const live = new Map([[1, make(1, `0`)]]) - input.pipe( - topK((a, b) => a.value.localeCompare(b.value) || a.id - b.id, { - limit, - }), - output((message) => { - for (const [[, [row]]] of message.getInner()) - expect(row.payload).toBe(row) - rows.add(message.getInner()) - }), - ) - graph.finalize() - const check = () => - rows.expectRows( - [...values] - .sort(([a, av], [b, bv]) => av.localeCompare(bv) || a - b) - .slice(0, limit) - .map(([id, value]) => [id, id, value]), - ) - input.sendData(new MultiSet([[[1, live.get(1)!], 1]])) - graph.run() - check() - for (const { key, value, additionFirst } of steps) { - const changes: Array<[[number, Row], number]> = [] - const before = live.get(key) - if (before) changes.push([[key, before], -1]) - if (value === null) { - values.delete(key) - live.delete(key) - } else { - const next = make(key, String(value)) - values.set(key, String(value)) - live.set(key, next) - changes.push([[key, next], 1]) - } - // Independently allocated equal cycles must cancel even when - // their deltas straddle a real replacement in separate messages. - changes.push([[key, make(key, `transient`)], 1]) - changes.unshift([[key, make(key, `transient`)], -1]) - if (additionFirst) changes.reverse() - for (const change of changes) - input.sendData(new MultiSet([change])) - graph.run() - check() - } - }, - ), - { - seed, - numRuns: oracleRuns(30), + { + name: `array`, + topK: topKWithFractionalIndex, + cyclicPayloads: `ivm.topk-cyclic-payloads-array`, + outerJoinReplacements: `ivm.topk-outer-join-replacements-array`, + }, + { + name: `grouped array`, + topK: groupedWindow, + cyclicPayloads: `ivm.topk-cyclic-payloads-grouped`, + outerJoinReplacements: `ivm.topk-outer-join-replacements-grouped`, + }, +])( + 'Generated fractional $name windows', + ({ topK, cyclicPayloads, outerJoinReplacements }) => { + it.each([409036, undefined])( + `preserves cyclic payloads through replacements and fresh transients (seed %s)`, + (seed) => { + const parameters = { + ...(seed === undefined + ? oraclePropertyOptions(30, cyclicPayloads) + : oracleFixedSeed(seed, { numRuns: oracleRuns(30) })), examples: [ [ false, @@ -172,75 +122,99 @@ describe.each([ ], ], ], - }, - ) - }, - ) - - it.each([409032, undefined])( - `matches outer-join replacements (seed %s)`, - (seed) => { - fc.assert( - fc.property( - fc.integer({ min: 0, max: 4 }), - fc.array( - fc.record({ - key: fc.integer({ min: 1, max: 4 }), - value: fc.option(fc.constantFrom(`a`, `b`, `c`, `d`), { - nil: null, + } + oracleRoute(oracleRouteOf(parameters)) + fc.assert( + fc.property( + fc.boolean(), + fc.integer({ min: 0, max: 3 }), + fc.array( + fc.record({ + key: fc.integer({ min: 1, max: 3 }), + value: fc.option(fc.integer({ min: 0, max: 5 }), { nil: null }), + additionFirst: fc.boolean(), }), - }), - { minLength: 1, maxLength: 20 }, - ), - (limit, steps) => { - const graph = new D2() - const left = graph.newInput<[number, string]>() - const right = graph.newInput<[number, string]>() - const rows = new TopKRelation() - const matches = new Map() - left.pipe( - join(right, `left`), - map( - ([id, [fallback, matched]]) => - [id, { id, value: matched ?? fallback }] as [ - number, - { id: number; value: string }, - ], - ), - topK((a, b) => a.value.localeCompare(b.value), { limit }), - output((message) => rows.add(message.getInner())), - ) - graph.finalize() - left.sendData( - new MultiSet([1, 2, 3, 4].map((key) => [[key, `z`], 1])), - ) - graph.run() - for (const step of steps) { - const changes: Array<[[number, string], number]> = [] - const old = matches.get(step.key) - if (old !== undefined) changes.push([[step.key, old], -1]) - if (step.value === null) matches.delete(step.key) - else { - matches.set(step.key, step.value) - changes.push([[step.key, step.value], 1]) + { minLength: 1, maxLength: 20 }, + ), + (useClass, limit, steps) => { + class Row { + payload: unknown + constructor( + public id: number, + public value: string, + ) { + this.payload = this + } } - right.sendData(new MultiSet(changes)) - graph.run() - // Recompute the full left join, then sort and slice. No incremental - // state or top-K helper determines the expected result. - rows.expectRows( - [1, 2, 3, 4] - .map((id) => ({ id, value: matches.get(id) ?? `z` })) - .sort((a, b) => a.value.localeCompare(b.value) || a.id - b.id) - .slice(0, limit) - .map(({ id, value }) => [id, id, value]), + const make = (id: number, value: string) => { + if (useClass) return new Row(id, value) + const row = { id, value, payload: undefined as unknown } + row.payload = row + return row + } + const graph = new D2() + const input = graph.newInput<[number, Row]>() + const rows = new TopKRelation() + const values = new Map([[1, `0`]]) + const live = new Map([[1, make(1, `0`)]]) + input.pipe( + topK((a, b) => a.value.localeCompare(b.value) || a.id - b.id, { + limit, + }), + output((message) => { + for (const [[, [row]]] of message.getInner()) + expect(row.payload).toBe(row) + rows.add(message.getInner()) + }), ) - } - }, - ), - { - seed, - numRuns: oracleRuns(100), + graph.finalize() + const check = () => + rows.expectRows( + [...values] + .sort(([a, av], [b, bv]) => av.localeCompare(bv) || a - b) + .slice(0, limit) + .map(([id, value]) => [id, id, value]), + ) + input.sendData(new MultiSet([[[1, live.get(1)!], 1]])) + graph.run() + check() + for (const { key, value, additionFirst } of steps) { + const changes: Array<[[number, Row], number]> = [] + const before = live.get(key) + if (before) changes.push([[key, before], -1]) + if (value === null) { + values.delete(key) + live.delete(key) + } else { + const next = make(key, String(value)) + values.set(key, String(value)) + live.set(key, next) + changes.push([[key, next], 1]) + } + // Independently allocated equal cycles must cancel even when + // their deltas straddle a real replacement in separate messages. + changes.push([[key, make(key, `transient`)], 1]) + changes.unshift([[key, make(key, `transient`)], -1]) + if (additionFirst) changes.reverse() + for (const change of changes) + input.sendData(new MultiSet([change])) + graph.run() + check() + } + }, + ), + parameters, + ) + }, + ) + + it.each([409032, undefined])( + `matches outer-join replacements (seed %s)`, + (seed) => { + const campaignParameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, outerJoinReplacements) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), examples: [ [ 1, @@ -250,159 +224,224 @@ describe.each([ ], ], ], - }, - ) - }, - ) - it.each( - [409031, undefined].flatMap((seed) => - [false, true].map((collision) => ({ seed, collision })), - ), - )( - `matches a signed cumulative relation through keyed histories (seed $seed, collision $collision)`, - ({ seed, collision }) => { - // The reference relation observes full rows and signed weights. Forcing - // every bucket to collide must not change what the graph publishes. - if (collision) vi.spyOn(hashing, `hash`).mockReturnValue(7) - fc.assert( - fc.property( - fc.record({ - limit: fc.integer({ min: 0, max: 6 }), - offset: fc.integer({ min: 0, max: 3 }), - split: fc.boolean(), - transient: fc.boolean(), - mapped: fc.boolean(), - transientValue: fc.constantFrom(`a`, `b`, `c`, `d`, `transient`), - steps: fc.array( + } + oracleRoute(oracleRouteOf(campaignParameters)) + fc.assert( + fc.property( + fc.integer({ min: 0, max: 4 }), + fc.array( fc.record({ - key: fc.integer({ min: 1, max: 8 }), - value: fc.constantFrom(`a`, `b`, `c`, `d`), - remove: fc.boolean(), - insertFirst: fc.boolean(), + key: fc.integer({ min: 1, max: 4 }), + value: fc.option(fc.constantFrom(`a`, `b`, `c`, `d`), { + nil: null, + }), }), - { minLength: 1, maxLength: 30 }, + { minLength: 1, maxLength: 20 }, ), - }), - ({ - limit, - offset, - split, - transient, - mapped, - transientValue, - steps, - }) => { - type Row = { id: number; value: string } - const graph = new D2() - const input = graph.newInput<[number, Row]>() - const relation = new TopKRelation() - const source = new Map() - const projected = mapped - ? input.pipe( - map(([key, row]) => [key, { ...row }] as [number, Row]), + (limit, steps) => { + const graph = new D2() + const left = graph.newInput<[number, string]>() + const right = graph.newInput<[number, string]>() + const rows = new TopKRelation() + const matches = new Map() + left.pipe( + join(right, `left`), + map( + ([id, [fallback, matched]]) => + [id, { id, value: matched ?? fallback }] as [ + number, + { id: number; value: string }, + ], + ), + topK((a, b) => a.value.localeCompare(b.value), { limit }), + output((message) => rows.add(message.getInner())), + ) + graph.finalize() + left.sendData( + new MultiSet([1, 2, 3, 4].map((key) => [[key, `z`], 1])), + ) + graph.run() + for (const step of steps) { + const changes: Array<[[number, string], number]> = [] + const old = matches.get(step.key) + if (old !== undefined) changes.push([[step.key, old], -1]) + if (step.value === null) matches.delete(step.key) + else { + matches.set(step.key, step.value) + changes.push([[step.key, step.value], 1]) + } + right.sendData(new MultiSet(changes)) + graph.run() + // Recompute the full left join, then sort and slice. No incremental + // state or top-K helper determines the expected result. + rows.expectRows( + [1, 2, 3, 4] + .map((id) => ({ id, value: matches.get(id) ?? `z` })) + .sort( + (a, b) => a.value.localeCompare(b.value) || a.id - b.id, + ) + .slice(0, limit) + .map(({ id, value }) => [id, id, value]), ) - : input - projected.pipe( - topK((a, b) => a.value.localeCompare(b.value), { - limit, - offset, - }), - output((message) => { - // An always-empty window needs no transfer work. Signed - // consolidation alone would hide a cancelling in/out pair. - if (limit === 0) expect(message.getInner()).toEqual([]) - relation.add(message.getInner()) - }), - ) - graph.finalize() - const initial = [1, 2, 3].map((key) => ({ - key, - value: `b`, - remove: false, - insertFirst: false, - })) - for (const step of [...initial, ...steps]) { - const changes: Array<[[number, Row], number]> = [] - const old = source.get(step.key) - if (old) changes.push([[step.key, { ...old }], -1]) - source.delete(step.key) - if (!step.remove) { - const row = { id: step.key, value: step.value } - source.set(step.key, row) - changes.push([[step.key, { ...row }], 1]) } - if (transient) { - const intermediate: [number, Row] = [ - step.key, - { id: step.key, value: transientValue }, - ] - changes.push([intermediate, 1], [intermediate, -1]) + }, + ), + campaignParameters, + ) + }, + ) + it.each( + [409031, undefined].flatMap((seed) => + [false, true].map((collision) => ({ seed, collision })), + ), + )( + `matches a signed cumulative relation through keyed histories (seed $seed, collision $collision)`, + ({ seed, collision }) => { + oracleRoute(seed === undefined ? `random seed` : `fixed seed`) + // The reference relation observes full rows and signed weights. Forcing + // every bucket to collide must not change what the graph publishes. + if (collision) vi.spyOn(hashing, `hash`).mockReturnValue(7) + fc.assert( + fc.property( + fc.record({ + limit: fc.integer({ min: 0, max: 6 }), + offset: fc.integer({ min: 0, max: 3 }), + split: fc.boolean(), + transient: fc.boolean(), + mapped: fc.boolean(), + transientValue: fc.constantFrom(`a`, `b`, `c`, `d`, `transient`), + steps: fc.array( + fc.record({ + key: fc.integer({ min: 1, max: 8 }), + value: fc.constantFrom(`a`, `b`, `c`, `d`), + remove: fc.boolean(), + insertFirst: fc.boolean(), + }), + { minLength: 1, maxLength: 30 }, + ), + }), + ({ + limit, + offset, + split, + transient, + mapped, + transientValue, + steps, + }) => { + type Row = { id: number; value: string } + const graph = new D2() + const input = graph.newInput<[number, Row]>() + const relation = new TopKRelation() + const source = new Map() + const projected = mapped + ? input.pipe( + map(([key, row]) => [key, { ...row }] as [number, Row]), + ) + : input + projected.pipe( + topK((a, b) => a.value.localeCompare(b.value), { + limit, + offset, + }), + output((message) => { + // An always-empty window needs no transfer work. Signed + // consolidation alone would hide a cancelling in/out pair. + if (limit === 0) expect(message.getInner()).toEqual([]) + relation.add(message.getInner()) + }), + ) + graph.finalize() + const initial = [1, 2, 3].map((key) => ({ + key, + value: `b`, + remove: false, + insertFirst: false, + })) + for (const step of [...initial, ...steps]) { + const changes: Array<[[number, Row], number]> = [] + const old = source.get(step.key) + if (old) changes.push([[step.key, { ...old }], -1]) + source.delete(step.key) + if (!step.remove) { + const row = { id: step.key, value: step.value } + source.set(step.key, row) + changes.push([[step.key, { ...row }], 1]) + } + if (transient) { + const intermediate: [number, Row] = [ + step.key, + { id: step.key, value: transientValue }, + ] + changes.push([intermediate, 1], [intermediate, -1]) + } + if (step.insertFirst) changes.reverse() + if (split) { + for (const change of changes) + input.sendData(new MultiSet([change])) + } else input.sendData(new MultiSet(changes)) + graph.run() + const expected = [...source.values()] + .sort((a, b) => a.value.localeCompare(b.value) || a.id - b.id) + .slice(offset, offset + limit) + .map((row): [number, number, string] => [ + row.id, + row.id, + row.value, + ]) + relation.expectRows(expected) } - if (step.insertFirst) changes.reverse() - if (split) { - for (const change of changes) - input.sendData(new MultiSet([change])) - } else input.sendData(new MultiSet(changes)) - graph.run() - const expected = [...source.values()] - .sort((a, b) => a.value.localeCompare(b.value) || a.id - b.id) - .slice(offset, offset + limit) - .map((row): [number, number, string] => [ - row.id, - row.id, - row.value, - ]) - relation.expectRows(expected) - } - }, - ), - { - seed, - numRuns: oracleRuns(100), - examples: [ - [ - { - offset: 1, - limit: 1, - split: true, - transient: true, - mapped: true, - transientValue: `a`, - steps: [ - { key: 2, value: `b`, remove: true, insertFirst: false }, - { key: 1, value: `a`, remove: false, insertFirst: true }, - ], - }, - ], - ...[0, 1, 3].flatMap((offset) => - [0, 1].map((limit) => [ + }, + ), + { + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + numRuns: oracleRuns(100), + examples: [ + [ { - offset, - limit, + offset: 1, + limit: 1, split: true, transient: true, mapped: true, transientValue: `a`, steps: [ - { key: 4, value: `a`, remove: false, insertFirst: true }, - { key: 2, value: `c`, remove: false, insertFirst: true }, - { key: 1, value: `b`, remove: true, insertFirst: false }, - { key: 1, value: `d`, remove: false, insertFirst: false }, + { key: 2, value: `b`, remove: true, insertFirst: false }, + { key: 1, value: `a`, remove: false, insertFirst: true }, ], }, - ]), - ), - ], - }, - ) - }, - ) -}) + ], + ...[0, 1, 3].flatMap((offset) => + [0, 1].map((limit) => [ + { + offset, + limit, + split: true, + transient: true, + mapped: true, + transientValue: `a`, + steps: [ + { key: 4, value: `a`, remove: false, insertFirst: true }, + { key: 2, value: `c`, remove: false, insertFirst: true }, + { key: 1, value: `b`, remove: true, insertFirst: false }, + { key: 1, value: `d`, remove: false, insertFirst: false }, + ], + }, + ]), + ), + ], + }, + ) + }, + ) + }, +) describe(`Operators`, () => { describe(`TopKWithFractionalIndex operator`, () => { const topK = topKWithFractionalIndex it(`should assign fractional indices to sorted elements`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const tracker = new TopKMessageTracker() @@ -490,6 +529,7 @@ describe(`Operators`, () => { }) it(`should support duplicate ordering keys`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const tracker = new TopKMessageTracker() @@ -572,6 +612,7 @@ describe(`Operators`, () => { }) it(`should handle limit and offset correctly`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const tracker = new TopKMessageTracker() @@ -658,6 +699,7 @@ describe(`Operators`, () => { }) it(`should handle elements moving positions correctly`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const tracker = new TopKMessageTracker() @@ -743,6 +785,7 @@ describe(`Operators`, () => { }) it(`should maintain lexicographic order through multiple updates`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const tracker = new TopKMessageTracker() @@ -846,6 +889,7 @@ describe(`Operators`, () => { }) it(`should maintain correct order when cycling through multiple changes`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const tracker = new TopKMessageTracker() @@ -953,6 +997,7 @@ describe(`Operators`, () => { }) it(`should handle insertion at the start of the sorted collection`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const allMessages: Array = [] @@ -1047,6 +1092,7 @@ describe(`Operators`, () => { }) it(`should handle multiple insertion at the start of the sorted collection`, () => { + oracleRoute(`case`) const graph = new D2() const input = graph.newInput<[number, { id: number; value: string }]>() const allMessages: Array = [] diff --git a/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts b/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts index dbaae8c8c8..200a6d4cc1 100644 --- a/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-batch-contract-oracle.test.ts @@ -7,7 +7,19 @@ import { registerOpaqueHash } from '../../src/hashing/hash.js' import { topKBatch } from '../../src/operators/topKState.js' import { D2 } from '../../src/d2.js' import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalIndex.js' +import { + oracleFixedSeed, + oracleRoute, + oracleRouteOf, +} from '../../../db/tests/oracle-config.js' import { output } from '../../src/operators/index.js' +import { declareOracle } from '../../../db/tests/oracle-declaration' + +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/topKState.ts#topKBatch`], +}) afterEach(() => vi.restoreAllMocks()) @@ -199,6 +211,8 @@ it.each( `preserves $name cancellation (large group $largeGroup, collision $collision)`, ({ pair, largeGroup, collision }) => { if (collision) vi.spyOn(hashing, `hash`).mockReturnValue(7) + const parameters = oracleFixedSeed(409033, { numRuns: 25 }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property( fc.integer({ min: 0, max: 254 }), @@ -256,7 +270,7 @@ it.each( expect(live[0]![1]).toBe(1) }, ), - { seed: 409033, numRuns: 25 }, + parameters, ) if (collision) expect(hashing.hash).not.toHaveBeenCalled() }, @@ -268,6 +282,8 @@ it.each([true, false])( const descriptor = Object.getOwnPropertyDescriptor(globalThis, `File`) if (!available) Reflect.deleteProperty(globalThis, `File`) try { + const parameters = oracleFixedSeed(409039, { numRuns: 25 }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(fc.integer(), fc.boolean(), (rank, retractFirst) => { const graph = new D2() @@ -297,7 +313,7 @@ it.each([true, false])( expect(live[0]![0]).toBe(after) expect(live[0]![1]).toBe(1) }), - { seed: 409039, numRuns: 25 }, + parameters, ) } finally { if (descriptor) Object.defineProperty(globalThis, `File`, descriptor) @@ -307,6 +323,8 @@ it.each([true, false])( it(`compares shared subtrees without expanding every path`, () => { vi.spyOn(hashing, `hash`).mockReturnValue(7) + const campaignParameters = oracleFixedSeed(409034, { numRuns: 25 }) + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property(fc.integer({ min: 0, max: 20 }), (depth) => { let reads = 0 @@ -332,11 +350,13 @@ it(`compares shared subtrees without expanding every path`, () => { expect(actual).toEqual([]) expect(reads).toBe(2) }), - { seed: 409034, numRuns: 25 }, + campaignParameters, ) }) it(`short-circuits replacements without hashing unrelated payloads`, () => { + const runParameters = oracleFixedSeed(409035, { numRuns: 25 }) + oracleRoute(oracleRouteOf(runParameters)) fc.assert( fc.property(fc.integer({ min: 1, max: 100 }), (count) => { let reads = 0 @@ -354,11 +374,13 @@ it(`short-circuits replacements without hashing unrelated payloads`, () => { expect([...topKBatch([new MultiSet(batch)])]).toHaveLength(count * 2) expect(reads).toBe(0) }), - { seed: 409035, numRuns: 25 }, + runParameters, ) }) it(`cancels long same-key histories with independently allocated values`, () => { + const parameters = oracleFixedSeed(409037, { numRuns: 25 }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(fc.integer({ min: 10, max: 150 }), (count) => { const make = (value: number) => ({ value }) @@ -369,6 +391,6 @@ it(`cancels long same-key histories with independently allocated values`, () => changes.push([[1, make(value)], -1]) expect([...topKBatch([new MultiSet(changes)])]).toEqual([]) }), - { seed: 409037, numRuns: 25 }, + parameters, ) }) diff --git a/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts b/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts index bbd53dcf5c..dae71f9294 100644 --- a/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { declareOracle } from '../../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../../db/tests/oracle-config.js' import { TopKRelation } from './topk-relation-oracle.js' +// What this oracle owns: nothing. It calibrates the TopKRelation checker +// against planted wrong answers and drives no production operator, so no +// `owns` entry applies. declareOracle({ check: [TopKRelation] }) /** @@ -20,6 +24,7 @@ declareOracle({ check: [TopKRelation] }) describe(`Signed top-K observation controls`, () => { it(`shrinks and replays duplicate-weight detection through the same checker`, () => { + oracleRoute(`calibration`) const property = fc.property(fc.integer({ min: 10, max: 100 }), (id) => { const valid = new TopKRelation() valid.add([[[null, [{ id, value: `a` }, `a0`]], 1]]) @@ -44,6 +49,7 @@ describe(`Signed top-K observation controls`, () => { it.each([`index`, `retraction`, `weight`, `update`] as const)( `rejects a wrong moved-row %s while preserving unchanged anchors`, (fault) => { + oracleRoute(`calibration`) const check = (corrupt: boolean) => { const relation = new TopKRelation() relation.add([ @@ -85,6 +91,7 @@ describe(`Signed top-K observation controls`, () => { ) it(`retains negative residue and copies captured payloads`, () => { + oracleRoute(`case`) const row = { id: 1, value: `a` } const relation = new TopKRelation() relation.add([[[null, [row, `a0`]], 1]]) diff --git a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts index a11acc87cb..40d4148f09 100644 --- a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts @@ -8,8 +8,11 @@ import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalI import { groupedTopKWithFractionalIndex } from '../../src/operators/groupedTopKWithFractionalIndex.js' import { orderByWithFractionalIndex } from '../../src/operators/orderBy.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../../db/tests/oracle-config.js' import { declareOracle } from '../../../db/tests/oracle-declaration.js' @@ -21,7 +24,14 @@ import { import { TopKRelation } from './topk-relation-oracle.js' import type { TopKWithFractionalIndexOptions } from '../../src/operators/topKWithFractionalIndex.js' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/operators/topKWithFractionalIndex.ts#topKWithFractionalIndex`, + `src/operators/groupedTopKWithFractionalIndex.ts#groupedTopKWithFractionalIndex`, + `src/operators/orderBy.ts#orderByWithFractionalIndex`, + ], check: [TopKRelation, checkNumericWindow], model: [selectedWindowKeys, requestedWindowChanged], }) @@ -38,13 +48,13 @@ declareOracle({ * calibrate that checker without exercising production. * * The grammar covers five finite scalar keys, one fixed payload per key, - * support 0..3, and offset/limit 0..6. The numeric-window lane also reaches + * support 0..3, and offset/limit 0..6. The numeric-window grammar also reaches * safe-integer offsets and widths near Number.MAX_SAFE_INTEGER. Every input turn and window move has * a graph-run checkpoint for output rows and reported window size. - * The pinned 0/1/2 support crossing and boundary history distinguish presence + * The fixed 0/1/2 support crossing and boundary history distinguish presence * from raw multiplicity and inclusive from displaced window cuts. Row replacements, * multiple groups, negative net support, and downstream lazy acquisition are - * outside this owner. The numeric lane does observe the empty output message + * outside this owner. The numeric-window grammar does observe the empty output message * that lets a downstream graph respond to changed requested demand. */ @@ -84,7 +94,7 @@ const campaigns = [ { name: `fixed`, test: oracleReplayTest(it), - parameters: { numRuns: runs, seed: 409033 }, + parameters: oracleFixedSeed(409033, { numRuns: runs }), }, { name: `random`, @@ -136,6 +146,11 @@ describe.each([ message: string } | undefined + const campaignParameters = { + ...campaign.parameters, + examples: [[boundaryHistory]], + } + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property( fc.array( @@ -223,7 +238,7 @@ describe.each([ expect(size()).toBe(selected.length), ) } - // Pin positivity crossings before every generated history. + // Fix positivity crossings before every generated history. const history = [1, 2, 1, 0, 2, 1, 0].map((support) => ({ slot: 0, support, @@ -244,10 +259,7 @@ describe.each([ } }, ), - { - ...campaign.parameters, - examples: [[boundaryHistory]], - }, + campaignParameters, ) }, ) @@ -261,7 +273,7 @@ describe.each([ * message is this operator boundary's demand signal, not source completeness. * Extra graph messages remain permitted, including for unchanged requested sets. * - * This lane keeps three to five positive typed keys installed. It varies the + * This grammar keeps three to five positive typed keys installed. It varies the * installed size, near-maximum offset, finite width, and empty-range gap. * The four profiles distinguish overflowing arithmetic, work proportional to * absent ranks, exact width retention after a partial move, and lost demand @@ -384,7 +396,7 @@ describe.each([ { name: `fixed`, test: oracleReplayTest(it), - parameters: { numRuns: runs, seed: 2291 }, + parameters: oracleFixedSeed(2291, { numRuns: runs }), }, { name: `random`, @@ -412,6 +424,11 @@ describe.each([ let firstFailure: { step: number; error: NumericWindowMismatch } | undefined try { + const campaignParameters = { + ...campaign.parameters, + examples: [[numericWindowExample]], + } + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property(numericWindowInput, (generated) => { reach.beginHistory({ fixed: begun++ === 0 }) @@ -498,7 +515,7 @@ describe.each([ if (index === 7) reach.reach(`returned to installed rows`) } }), - { ...campaign.parameters, examples: [[numericWindowExample]] }, + campaignParameters, ) reach.assertReached() } finally { @@ -524,7 +541,7 @@ describe.each([ * with installed ranks; clamping its offset before applying width changes it. * Negative limits and fractional bounds have no authority here and stay out. * - * This lane adds the missing offset domain without replacing the support or + * This grammar adds the missing offset domain without replacing the support or * large-numeric campaigns. Installed cardinality, negative depth, width and gap * distinguish absolute intervals from JavaScript's end-relative slice indices. * A legal support tail adds nearby edits and moves. A constructive prefix @@ -536,7 +553,7 @@ describe.each([ * one-group top-K and the public orderBy wrapper. Where the public size getter * accepts a predicate, an always-true predicate must count the same selected * rows. Multiple groups, varying prefix predicates and downstream acquisition - * remain outside this lane. Mismatches + * remain outside this grammar. Mismatches * accumulate through the history so a size defect cannot hide row movement or * a source-edit defect. A runtime interruption remains a separate failure. */ @@ -713,6 +730,7 @@ declareOracle({ // wrong rows to the same signed-output checker; no production helper supplies // the expected answer, and no output normalization removes the distinction. it(`rejects end-relative slicing and offset-first clamping`, () => { + oracleRoute(`calibration`) const present = orderedKeys.slice(0, 3) for (const window of [ { offset: -2, limit: 1 }, @@ -769,6 +787,16 @@ describe.each([ const fidelity = createOracleFailureFidelity() let begun = 0 try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2316, { + numRuns: oracleRuns(100) + negativeWindowExamples.length, + examples: negativeWindowExamples, + }) + : oraclePropertyOptions(100, property, { + examples: negativeWindowExamples, + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(negativeWindowHistory, async (history) => { @@ -784,15 +812,7 @@ describe.each([ ), ) }), - campaign === `fixed` - ? { - seed: 2316, - numRuns: oracleRuns(100) + negativeWindowExamples.length, - examples: negativeWindowExamples, - } - : oraclePropertyOptions(100, property, { - examples: negativeWindowExamples, - }), + parameters, ), ) reach.assertReached() @@ -810,6 +830,7 @@ describe.each([ it.each([-2, 2])( `rejects excess signed weight %s across a moved window`, (weight) => { + oracleRoute(`calibration`) const check = (corrupt: boolean) => { const relation = new TopKRelation() relation.add([[[1, [{ id: 9, value: `tie` }, `a0`]], 1]]) 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 e82ee18118..c666bb9de7 100644 --- a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { equalHashValues, structuralIdentity } from '../src/hashing/hash.js' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -39,7 +42,7 @@ import { declareOracle } from '../../db/tests/oracle-declaration.js' * `lastIndex`, Maps and Sets with zero to two entries, arrays with an * enumerable non-index field, small binary values, holes, and nesting deeper * than the identity's depth cap. - * Pinned pairs cover distinct symbols with the same description, and different + * Fixed pairs cover distinct symbols with the same description, and different * object kinds with the same body. Hand-written collision pairs are ordinary * tests in structural-identity.test.ts. Fixed representation witnesses verify that the driver reaches each * prototype, key-order, and hidden-symbol variant it claims. The depth cap @@ -63,7 +66,7 @@ type Spec = const SYMBOL = Symbol(`s`) const ENUMERABLE_SYMBOL = Symbol(`enumerable`) -const { replayProperty, replaySeed } = readOracleRunConfig() +const { replayProperty, campaignSeed } = readOracleRunConfig() const LEAVES: ReadonlyArray = [ 0, -0, @@ -367,7 +370,16 @@ function expectAgreement( expect(equal, `a shared identity: ${leftIdentity}`).toBe(true) } -declareOracle({ check: [expectAgreement], model: [normal] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/hashing/hash.ts#structuralIdentity`, + `src/hashing/hash.ts#equalHashValues`, + ], + check: [expectAgreement], + model: [normal], +}) function decisions(...values: Array): () => boolean { let index = 0 @@ -437,12 +449,16 @@ describe(`structuralIdentity`, () => { 2000, `structural-identity.equal-representations`, ) - if (replaySeed === undefined) - await fc.assert(property, { numRuns: options.numRuns, seed: 20261014 }) + if (campaignSeed === undefined) { + const fixed = oracleFixedSeed(20261014, { numRuns: options.numRuns }) + oracleRoute(oracleRouteOf(fixed)) + await fc.assert(property, fixed) + } + oracleRoute(oracleRouteOf(options)) await fc.assert(property, options) }) - // Named replays of counterexamples the random campaign found. Nested `deep` + // Named fixed histories of counterexamples the random campaign found. Nested `deep` // nodes compose, so these specs build equal values; an empty text leaf and the // leaf it shares a value with must also normalize alike. it.each([ @@ -565,6 +581,7 @@ describe(`structuralIdentity`, () => { ] as Array<[string, Spec, Spec]>)( `agrees with D2 value identity for %s`, (_name, left, right) => { + oracleRoute(`case`) expectAgreement( left, right, @@ -597,8 +614,12 @@ describe(`structuralIdentity`, () => { 2000, `structural-identity.independent-values`, ) - if (replaySeed === undefined) - await fc.assert(property, { numRuns: options.numRuns, seed: 1158727847 }) + if (campaignSeed === undefined) { + const fixed = oracleFixedSeed(1158727847, { numRuns: options.numRuns }) + oracleRoute(oracleRouteOf(fixed)) + await fc.assert(property, fixed) + } + oracleRoute(oracleRouteOf(options)) await fc.assert(property, options) }) }) diff --git a/packages/db-ivm/tests/structural-identity.test.ts b/packages/db-ivm/tests/structural-identity.test.ts index ccc03ad3ef..4320d07c22 100644 --- a/packages/db-ivm/tests/structural-identity.test.ts +++ b/packages/db-ivm/tests/structural-identity.test.ts @@ -7,7 +7,7 @@ import { equalHashValues, structuralIdentity } from '../src/hashing/hash.js' // Different values whose parts, read without lengths, join to the same text. // A random four-character string never embeds a whole later part, so these -// pairs pin the cases that a missing length prefix would merge. +// pairs fix the cases that a missing length prefix would merge. const collisionPairs: Array<[string, unknown, unknown]> = [ [`string spans a later key`, { a: `x|1:b="y` }, { a: `x`, b: `y` }], [`key spans a later key`, { [`a="1:x|b`]: `y` }, { a: `x`, b: `y` }], diff --git a/packages/db-ivm/tests/temporal-group-key-oracle.test.ts b/packages/db-ivm/tests/temporal-group-key-oracle.test.ts index 5bb5b1e490..f6558a8cc5 100644 --- a/packages/db-ivm/tests/temporal-group-key-oracle.test.ts +++ b/packages/db-ivm/tests/temporal-group-key-oracle.test.ts @@ -9,6 +9,7 @@ import { serializeValue, } from '../src/index.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config.js' /** * Temporal values are valid db-ivm group keys. The established hash key domain @@ -77,7 +78,12 @@ function expectedGroupCounts(classes: ReadonlyArray): Array { } return [...counts.values()].sort((left, right) => left - right) } -declareOracle({ model: [expectedGroupCounts] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/operators/groupBy.ts#groupBy`], + model: [expectedGroupCounts], +}) function observedGroupCounts(rows: Array<{ date: object }>): Array { const graph = new D2() @@ -113,6 +119,7 @@ describe('Temporal group keys', () => { it.each(temporalCases)( '$name keeps distinct values in distinct groups', ({ first, second }) => { + oracleRoute(`case`) const rows = [{ date: first() }, { date: second() }] expect(observedGroupCounts(rows)).toEqual( expectedGroupCounts(['first', 'second']), @@ -126,6 +133,7 @@ describe('Temporal group keys', () => { it.each(temporalCases)( '$name groups matching fresh values together', ({ first }) => { + oracleRoute(`case`) const rows = [{ date: first() }, { date: first() }] expect(serializeValue(rows[0]!.date)).toBe(serializeValue(rows[1]!.date)) expect(observedGroupCounts(rows)).toEqual( @@ -135,6 +143,7 @@ describe('Temporal group keys', () => { ) it('keeps equal-comparing Duration representations in separate groups', () => { + oracleRoute(`case`) const hour = Temporal.Duration.from('PT1H') const minutes = Temporal.Duration.from('PT60M') expect(Temporal.Duration.compare(hour, minutes)).toBe(0) @@ -144,6 +153,7 @@ describe('Temporal group keys', () => { }) it('keeps a plain object with a Temporal tag distinct from a Temporal value', () => { + oracleRoute(`case`) const date = Temporal.PlainDate.from('2024-01-15') const taggedObject = { [Symbol.toStringTag]: 'Temporal.PlainDate', @@ -161,6 +171,7 @@ describe('Temporal group keys', () => { }) it('keeps an own-tagged plain object with no enumerable fields distinct', () => { + oracleRoute(`case`) const date = Temporal.PlainDate.from('2024-01-15') const taggedObject = {} Object.defineProperty(taggedObject, Symbol.toStringTag, { @@ -177,6 +188,7 @@ describe('Temporal group keys', () => { }) it('still groups a Temporal value with an own matching tag', () => { + oracleRoute(`case`) const date = Temporal.PlainDate.from('2024-01-15') const matchingDate = Temporal.PlainDate.from('2024-01-15') Object.defineProperty(matchingDate, Symbol.toStringTag, { diff --git a/packages/db-ivm/vite.config.ts b/packages/db-ivm/vite.config.ts index 2ba3aad1cf..9c68e67dbc 100644 --- a/packages/db-ivm/vite.config.ts +++ b/packages/db-ivm/vite.config.ts @@ -1,10 +1,13 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' import { commonJsDeclarations } from '../../scripts/commonjs-declarations.mjs' +import { stripOracleProbes } from '../../scripts/strip-oracle-probes.mjs' import { publishableModuleOutput } from '../../scripts/publishable-module-output.mjs' import packageJson from './package.json' const config = defineConfig({ + // The published build carries no oracle state probes. + plugins: [stripOracleProbes()], build: { rollupOptions: { output: publishableModuleOutput, diff --git a/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts index cfba613d5b..64847774f9 100644 --- a/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts @@ -1,8 +1,14 @@ import { DatabaseSync } from 'node:sqlite' -import { describe, expect, it } from 'vitest' import { SQLiteCorePersistenceAdapter, createPersistedTableName } from '../src' -import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { + differs, + oracleProperty, + pass, + violation, +} from '../../db/tests/oracle-property.js' +import { declareOracle } from '../../db/tests/oracle-declaration' import type { PersistedTx, SQLiteDriver } from '../src' +import type { Observation as TraceEntry } from '../../db/tests/oracle-property.js' /** * # Does reopening a persisted Collection repair setup without redoing it? @@ -26,13 +32,15 @@ import type { PersistedTx, SQLiteDriver } from '../src' * read after setup is cached in memory. A partial state may cost more. * * Model: a starting state is the set of setup facts present. The model names - * the seven schema objects (type, name, table) from the fact list itself. - * Their SQL text is taken from a production fresh install on a separate - * database, so that part checks reopen against fresh install rather than - * the absolute trigger and table definitions. The expected rows are the - * seeded rows. A state is complete exactly when every fact is present. + * the seven schema objects (type, name, table) from the fact list itself. The + * expected rows are the seeded rows. A state is complete exactly when every + * fact is present. SQL text is not in the model: the driver also records a + * production fresh install on a separate database, and the check compares + * the reopened schema with it (a differential law), and the fresh install's + * objects with the model's. * - * History grammar, on one node:sqlite database: + * History grammar, each on its own node:sqlite database (the driver also + * opens a second one for the fresh install it compares with): * - Interrupted setup: the registry row exists and setup stopped after the * first k of the nine facts, for k in 0..9, in production setup order. No * rows exist yet, because no write can precede complete setup. @@ -50,7 +58,11 @@ import type { PersistedTx, SQLiteDriver } from '../src' * first reads another Collection, so global initialization is not measured. * It then calls `loadSubset` on the target Collection, and calls it again for * the cached baseline. The setup and row observations are taken after the - * first `loadSubset` resolves. + * first `loadSubset` resolves. Each history opens two in-memory databases + * and closes both however the history ends. + * + * The property is `sqlite-persistence.setup-reopen` on `oracleProperty`, + * enumerated over all 140 starting states, each its own test. */ type Row = { id: string; title: string } @@ -259,14 +271,13 @@ function bookkeeping(database: DatabaseSync): { const readOptions = {} const target = (id: string) => ({ kind: `eager` as const, collectionId: id }) -async function freshInstallSchema(): Promise> { - const database = new DatabaseSync(`:memory:`) +async function freshInstallSchema( + database: DatabaseSync, +): Promise> { const { driver } = countingDriver(database) const adapter = new SQLiteCorePersistenceAdapter({ driver }) await adapter.loadSubset(target(collectionId), readOptions) - const schema = collectionSchema(database) - database.close() - return schema + return collectionSchema(database) } type Start = { @@ -284,8 +295,10 @@ type Observation = { cachedReadCalls: number } -async function reopen(start: Start): Promise { - const database = new DatabaseSync(`:memory:`) +async function reopen( + start: Start, + database: DatabaseSync, +): Promise { const install = countingDriver(database) const installer = new SQLiteCorePersistenceAdapter({ driver: install.driver, @@ -321,7 +334,6 @@ async function reopen(start: Start): Promise { const cachedBefore = totalCalls(reload.counts) await adapter.loadSubset(target(collectionId), readOptions) const cachedReadCalls = totalCalls(reload.counts) - cachedBefore - database.close() return { schema, @@ -371,47 +383,186 @@ const starts: Array = [ ] // The model: what a fresh adapter's first read must leave behind. -function expectedRows(start: Start): Array { +function setupModel(start: Start) { const resets = start.reopenSchemaVersion !== undefined - return start.seedRows && !resets ? seededRows : [] + return { + objects: modelSchemaObjects(), + rows: start.seedRows && !resets ? seededRows : [], + // The work law applies only when all nine facts hold and nothing resets. + workLawApplies: start.absent.length === 0 && !resets, + } } -function workLawApplies(start: Start): boolean { - return start.absent.length === 0 && start.reopenSchemaVersion === undefined +// The driver: a fresh install and a reopen, each on its own in-memory +// database, both closed however the history ends. +async function driveReopen( + start: Start, + observe: (checkpoint: string, value: unknown) => void, +) { + const databases: Array = [] + const open = () => { + const database = new DatabaseSync(`:memory:`) + databases.push(database) + return database + } + try { + observe(`fresh install`, await freshInstallSchema(open())) + observe(`reopened`, await reopen(start, open())) + } finally { + for (const database of databases) + try { + database.close() + } catch { + // A database that failed to open has nothing to release. + } + } } -declareOracle({ model: [modelSchemaObjects] }) - -describe(`collection setup reopen oracle`, () => { - it(`repairs every legal starting state to a fresh install and skips complete setup`, async () => { - const expectedSchema = await freshInstallSchema() - // The model's seven objects, independent of production setup. - expect( - expectedSchema.map(({ type, name, tbl_name }) => ({ - type, - name, - tbl_name, - })), - ).toEqual(modelSchemaObjects()) - - for (const start of starts) { - const observed = await reopen(start) - expect(observed.schema, `${start.name}: schema`).toEqual(expectedSchema) - expect(observed.bookkeeping, `${start.name}: bookkeeping`).toEqual({ - version: true, - epoch: true, - }) - expect(observed.rows, `${start.name}: rows`).toEqual(expectedRows(start)) - if (workLawApplies(start)) { - expect( - { exec: observed.openCalls.exec, run: observed.openCalls.run }, - `${start.name}: complete setup issues no DDL or writes`, - ).toEqual({ exec: 0, run: 0 }) - expect( - totalCalls(observed.openCalls) - observed.cachedReadCalls, - `${start.name}: setup overhead over a cached read`, - ).toBeLessThanOrEqual(1) - } - } - }) +// The check: the fresh install has the model's seven objects; the reopened +// Collection's schema equals the fresh install's, both bookkeeping rows +// exist, and its rows are the model's; when the work law applies, opening +// issues no DDL or writes and costs at most one call over a cached read. +function checkReopen( + _start: Start, + trace: ReadonlyArray, + owed: ReturnType, +) { + const fresh = trace.find(({ checkpoint }) => checkpoint === `fresh install`) + ?.value as Array | undefined + const seen = trace.find(({ checkpoint }) => checkpoint === `reopened`) + ?.value as Observation | undefined + if (!fresh || !seen) + return violation(`reopened`, `the driver recorded no reopen`, `trace`) + const wrong = + differs( + `fresh install`, + `fresh-install`, + fresh.map(({ type, name, tbl_name }) => ({ type, name, tbl_name })), + owed.objects, + `a fresh install created other schema objects than the model names`, + ) ?? + differs( + `reopened`, + `setup`, + seen.schema, + fresh, + `the reopened schema differs from a fresh install`, + ) ?? + differs( + `reopened`, + `bookkeeping`, + seen.bookkeeping, + { version: true, epoch: true }, + `a bookkeeping row is missing`, + ) ?? + differs(`reopened`, `rows`, seen.rows, owed.rows, `stored rows changed`) + if (wrong || !owed.workLawApplies) return wrong ?? pass + return ( + differs( + `reopened`, + `work`, + { exec: seen.openCalls.exec, run: seen.openCalls.run }, + { exec: 0, run: 0 }, + `complete setup issued DDL or writes`, + ) ?? + (totalCalls(seen.openCalls) - seen.cachedReadCalls > 1 + ? violation( + `reopened`, + `setup cost ${totalCalls(seen.openCalls) - seen.cachedReadCalls} calls over a cached read, owed at most 1`, + `work`, + ) + : pass) + ) +} + +declareOracle({ + owns: [`src/sqlite-core-adapter.ts`], + check: [checkReopen], + model: [setupModel], +}) + +oracleProperty({ + id: `sqlite-persistence.setup-reopen`, + law: { + statement: `After a fresh adapter's first read of a registered Collection, its schema objects are exactly a fresh install's (same type, name, table, and SQL), both bookkeeping rows exist, and stored rows are unchanged, from every legal starting state; when all nine setup facts already hold for the registered schema version, the read issues no DDL or writes and costs at most one call more than a cached read. A partial state may cost more.`, + authority: `the SQLite core adapter's per-Collection setup contract; the accepted persistence goal that reloads are fast (issue #2204)`, + limits: `In-memory node:sqlite databases; no cache-generation claims, concurrent adapters, or dropped row tables that still hold rows. SQL text is checked against a production fresh install, not absolute definitions.`, + owner: `SQLite persistence`, + }, + histories: starts, + label: (start) => start.name, + model: setupModel, + driver: driveReopen, + check: checkReopen, + calibrations: [ + { + name: `a trigger is not restored`, + law: `setup`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `reopened` + ? { + ...entry, + value: { + ...(entry.value as Observation), + schema: (entry.value as Observation).schema.filter( + ({ type }) => type !== `trigger`, + ), + }, + } + : entry, + ), + }, + { + name: `the reset epoch row is missing`, + law: `bookkeeping`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `reopened` + ? { + ...entry, + value: { + ...(entry.value as Observation), + bookkeeping: { version: true, epoch: false }, + }, + } + : entry, + ), + }, + { + name: `repair drops stored rows`, + law: `rows`, + corrupt: (trace) => { + const seen = trace.find(({ checkpoint }) => checkpoint === `reopened`) + ?.value as Observation | undefined + if (!seen?.rows.length) return undefined + return trace.map((entry) => + entry.checkpoint === `reopened` + ? { ...entry, value: { ...seen, rows: [] } } + : entry, + ) + }, + }, + { + name: `complete setup runs DDL again`, + law: `work`, + corrupt: (trace, start) => + setupModel(start).workLawApplies + ? trace.map((entry) => + entry.checkpoint === `reopened` + ? { + ...entry, + value: { + ...(entry.value as Observation), + openCalls: { + ...(entry.value as Observation).openCalls, + exec: 1, + }, + }, + } + : entry, + ) + : undefined, + }, + ], }) diff --git a/packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-oracle.ts b/packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-oracle.ts index 21ea4caf2a..01700c7ce5 100644 --- a/packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-oracle.ts @@ -1,6 +1,12 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it, vi } from 'vitest' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { createPersistedTableName } from '../src' import { @@ -941,6 +947,7 @@ async function assertGeneratedHistory( // committed work. The generated histories and independent-key cases come from // the grammars, and the work bound is the write-count law at each checkpoint. declareOracle({ + owns: [`src/sqlite-core-adapter.ts`], check: [assertGeneratedHistory, assertIndependentWork], model: [modelAfter], grammar: [generatedHistoryArbitrary, independentWorkArbitrary], @@ -1035,10 +1042,10 @@ export function runOrdinaryTransactionWorkOracle(): void { ) it('reconstructs the bounded generated action-history axes', () => { - const samples = fc.sample(generatedHistoryArbitrary, { - seed: 1992, - numRuns: 120, - }) + const samples = fc.sample( + generatedHistoryArbitrary, + oracleFixedSeed(1992, { numRuns: 120 }), + ) expect(new Set(samples.map((sample) => sample.scenario))).toEqual( new Set([ 'repeated-key', @@ -1082,10 +1089,10 @@ export function runOrdinaryTransactionWorkOracle(): void { }) it('reconstructs independent-key work shapes and host caps', () => { - const samples = fc.sample(independentWorkArbitrary, { - seed: 31992, - numRuns: 120, - }) + const samples = fc.sample( + independentWorkArbitrary, + oracleFixedSeed(31992, { numRuns: 120 }), + ) expect(new Set(samples.map((sample) => sample.shape))).toEqual( new Set([ 'insert-later-metadata', @@ -1104,33 +1111,49 @@ export function runOrdinaryTransactionWorkOracle(): void { ).toBe(true) }) - fcTest.prop([independentWorkArbitrary], { - seed: 31992, + // Each campaign registers the route of its parameters in every run. + const routed = + , TResult>( + parameters: Parameters[0], + check: (...args: TArgs) => TResult, + ) => + (...args: TArgs): TResult => { + oracleRoute(oracleRouteOf(parameters)) + return check(...args) + } + + const independentWorkFixedSeed = oracleFixedSeed(31992, { numRuns: oracleRuns(24), - })('bounds independent-key write work (fixed)', assertIndependentWork) + }) + fcTest.prop([independentWorkArbitrary], independentWorkFixedSeed)( + 'bounds independent-key write work (fixed)', + routed(independentWorkFixedSeed, assertIndependentWork), + ) - fcTest.prop( - [independentWorkArbitrary], - oraclePropertyOptions(24, 'sqlite-ordinary.independent-work'), - )( + const independentWorkCampaign = oraclePropertyOptions( + 24, + 'sqlite-ordinary.independent-work', + ) + fcTest.prop([independentWorkArbitrary], independentWorkCampaign)( 'bounds independent-key write work (random or replayed)', - assertIndependentWork, + routed(independentWorkCampaign, assertIndependentWork), ) - fcTest.prop([generatedHistoryArbitrary], { - seed: 1992, + const generatedHistoryFixedSeed = oracleFixedSeed(1992, { numRuns: oracleRuns(24), - })( + }) + fcTest.prop([generatedHistoryArbitrary], generatedHistoryFixedSeed)( 'preserves bounded ordinary write histories (fixed)', - assertGeneratedHistory, + routed(generatedHistoryFixedSeed, assertGeneratedHistory), ) - fcTest.prop( - [generatedHistoryArbitrary], - oraclePropertyOptions(24, 'sqlite-ordinary.write-history'), - )( + const generatedHistoryCampaign = oraclePropertyOptions( + 24, + 'sqlite-ordinary.write-history', + ) + fcTest.prop([generatedHistoryArbitrary], generatedHistoryCampaign)( 'preserves bounded ordinary write histories (random or replayed)', - assertGeneratedHistory, + routed(generatedHistoryCampaign, assertGeneratedHistory), ) it('rejects a wrong metadata-preservation design in the generated driver', async () => { diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts index cada689a62..94b6f027f0 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts @@ -8,9 +8,12 @@ import { eq, } from '@tanstack/db' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplaySkips, + oracleRoute, + oracleRouteOf, oracleRuns, recordOracleReplay, } from '../../db/tests/oracle-config' @@ -1179,14 +1182,15 @@ async function assertRun(property: string, result: fc.RunDetails) { throw new Error(message, { cause: result.errorInstance }) } -async function campaign(options: Parameters[1]) { - // A seed alone replays the whole campaign and must still enforce reach. +async function campaign(options: NonNullable[1]>) { + // A seed alone runs a reseeded campaign and must still enforce reach. // A path replays one input directly and does not owe campaign-wide reach. const reach = createOracleReach(PROPERTY, REACH_STATES, { replaying: oracleReplayRequested(), }) let seed: number | undefined if (!oracleReplaySkips(PROPERTY)) { + oracleRoute(oracleRouteOf(options)) const result = await fc.check( fc.asyncProperty(historyArb, (history) => checkHistory(history, reach)), options, @@ -1240,7 +1244,7 @@ async function campaign(options: Parameters[1]) { reach.assertReached() } catch (error) { throw new Error( - `${PROPERTY}: seed=${seed}, numRuns=${options?.numRuns}; replay the full campaign with this seed`, + `${PROPERTY}: seed=${seed}, numRuns=${options.numRuns}; replay the full campaign with this seed`, { cause: error }, ) } @@ -1262,6 +1266,7 @@ function checkRelease( } declareOracle({ + owns: [`src/persisted.ts`], check: [coverageViolations, checkFault, checkRelease], grammar: [historyArb, requiredHistoriesArb], }) @@ -1270,6 +1275,7 @@ describe(`persisted demand coverage over generated histories`, () => { it.skipIf(oracleReplayRequested())( `keeps the law when any one storage call fails`, async () => { + oracleRoute(`case`) quietConsole() const baseline = await runWithFault(undefined) // Positive witness: without a failure, session 2 settles D offline. @@ -1311,6 +1317,7 @@ describe(`persisted demand coverage over generated histories`, () => { it.skipIf(oracleReplayRequested())( `owns no wait after a release at any step of a load`, async () => { + oracleRoute(`case`) quietConsole() const driver = createDriver(new DatabaseSync(`:memory:`)) await coverGroupA(driver) @@ -1337,6 +1344,7 @@ describe(`persisted demand coverage over generated histories`, () => { it.skipIf(oracleReplayRequested())( `rejects observations that ignore lost rows, obsolete loads, or other window rows`, () => { + oracleRoute(`calibration`) // Checker calibration (ORC-006): the shared judgment must reject a wrong // observation of each kind. Each record below states what production // would wrongly show; the comparison must report it. @@ -1449,7 +1457,7 @@ describe(`persisted demand coverage over generated histories`, () => { `refines the model over a fixed campaign`, async () => { quietConsole() - await campaign({ seed: 2102, numRuns: oracleRuns(RUNS) }) + await campaign(oracleFixedSeed(2102, { numRuns: oracleRuns(RUNS) })) }, campaignTimeoutMs, ) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle.ts index 6e92d27111..cc858f25a5 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-model-oracle.ts @@ -554,4 +554,6 @@ export function coverageViolations( return violations } +// Owns nothing: this module holds the model and check only. The production +// path is driven by persisted-demand-coverage-oracle.test.ts and persisted-demand-coverage-campaign-oracle.test.ts, which declares the owned entry. declareOracle({ check: [coverageViolations], model: [expectedObservations] }) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts index 9c97efc803..d4d3c6e45c 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts @@ -13,6 +13,7 @@ import { persistedCollectionOptions, } from '../src/index.js' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createSerializedNodeSqliteDriver as createDriver } from './helpers/node-sqlite-driver.js' import { drain, @@ -88,7 +89,7 @@ import type { SQLiteDriver } from '../src/index.js' * with the generated campaign and every receiving driver, * `tests/persisted-demand-coverage-model-oracle.ts`. They record the * source's log and each query's loads and observation, and state no expected - * answer (ORC-016). Two pinned checks whose laws other owners hold live in + * answer (ORC-016). Two fixed regressions whose laws other owners hold live in * `persisted-demand-coverage-supplemental.test.ts`. * * Limits: Electric, Query DB, React, and Solid have receiving witnesses in @@ -2379,6 +2380,7 @@ afterEach(() => { }) declareOracle({ + owns: [`src/persisted.ts`], check: [coverageViolations, firstMismatch, sameObservation], model: [expectedTrace], grammar: [ALL_CELLS], @@ -2393,6 +2395,7 @@ describe(`persisted demand coverage oracle`, () => { // ------------------------------------------------------------------------- it(`rejects wrong observations at a checkpoint`, () => { + oracleRoute(`calibration`) const ready = { queryStatus: `ready`, preload: `resolved`, @@ -2561,6 +2564,7 @@ describe(`persisted demand coverage oracle`, () => { it.each(wrongDesigns.map((entry) => [entry.design.name, entry] as const))( `distinguishes the law from the wrong design: %s`, (_name, { design, witness }) => { + oracleRoute(`calibration`) expect(ALL_CELLS).toContainEqual(witness) expect( firstMismatch(expectedTrace(witness), expectedTrace(witness, design)), @@ -2569,6 +2573,7 @@ describe(`persisted demand coverage oracle`, () => { ) it(`enumerates every declared cell once`, () => { + oracleRoute(`case`) const names = ALL_CELLS.map(cellName) expect(new Set(names).size).toBe(names.length) expect(names).toHaveLength( @@ -2583,6 +2588,7 @@ describe(`persisted demand coverage oracle`, () => { it.each(ALL_CELLS.map((cell) => [cellName(cell), cell] as const))( `refines the model: %s`, async (_name, cell) => { + oracleRoute(`case`) quietConsole() const actual = await runCell(cell) expect(firstMismatch(expectedTrace(cell), actual)).toBeUndefined() @@ -2594,6 +2600,7 @@ describe(`persisted demand coverage oracle`, () => { // ------------------------------------------------------------------------- it(`gives each load of a covered demand its own wait and keeps coverage across release`, async () => { + oracleRoute(`fixed history`) quietConsole() const records = await runLifecycle() judge(records) @@ -2626,6 +2633,7 @@ describe(`persisted demand coverage oracle`, () => { ])( `makes a joined query ready only when its on-demand demands settle: %s, %s`, async (history, shape) => { + oracleRoute(`case`) quietConsole() const records = await runJoined(history, shape) judge(records) @@ -2668,6 +2676,7 @@ describe(`persisted demand coverage oracle`, () => { () => runClaimExpiredBeforeCheck([]), ], ] as const)(`judges a reset in the same session: %s`, async (_name, run) => { + oracleRoute(`case`) quietConsole() judge(await run()) }) @@ -2704,6 +2713,7 @@ describe(`persisted demand coverage oracle`, () => { () => runHeldPastTheWait(`claim renewal`), ], ] as const)(`judges coverage bookkeeping: %s`, async (_name, run) => { + oracleRoute(`case`) quietConsole() judge(await run()) }) @@ -2711,6 +2721,7 @@ describe(`persisted demand coverage oracle`, () => { it.each(Object.keys(WINDOW_HISTORIES) as Array)( `settles an ordered window from SQLite only while its demands are covered: %s`, async (history) => { + oracleRoute(`case`) quietConsole() judge(await runWindow(history)) }, @@ -2724,6 +2735,7 @@ describe(`persisted demand coverage oracle`, () => { ] as const)( `moves a window offline to a page only when that page is covered (session 1 loaded page 2: %s, indexed: %s)`, async (session1LoadsPageTwo, indexed) => { + oracleRoute(`case`) quietConsole() judge(await runNextPage(session1LoadsPageTwo, indexed)) }, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts index 7035909a08..9471c65c3f 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-supplemental.test.ts @@ -20,7 +20,7 @@ import type { SQLiteDriver } from '../src/index.js' /** * Supplemental checks beside the persisted demand coverage oracle - * (`persisted-demand-coverage-oracle.test.ts`). These pin two behaviors that + * (`persisted-demand-coverage-oracle.test.ts`). These check two behaviors that * the opt-in must leave as they were, and whose laws other owners hold, so * they are ordinary examples rather than inputs to the coverage judgment * (ORC-016): diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts index 157e49e1db..0fabb2becf 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand.test.ts @@ -2377,7 +2377,7 @@ describe(`persistedCollectionOptions`, () => { // The earlier history law cancelled only after remote acquisition had // begun. Holding the real adapter hydration seam exposes the distinct - // post-hydration transfer boundary. `retain` is the hostile control: + // post-hydration transfer boundary. `retain` is the mutant control: // the cancellation guard must not suppress live demand. expect({ ensures: ensure.mock.calls.length, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts index ac79dd30f0..8f830be788 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-follower-indexes.test.ts @@ -210,7 +210,7 @@ describe(`persistedCollectionOptions`, () => { await vi.advanceTimersByTimeAsync(200) // The old matrix supplied an AbortError or explicitly unloaded demand, // but never aborted an already-queued retry. The signal row owns that - // distinct boundary; offline is its hostile liveness control. + // distinct boundary; offline is its mutant liveness control. expect(result).toBe(failure) expect(followerUpstreamLoad).not.toHaveBeenCalled() if (action === `offline`) { @@ -1169,7 +1169,7 @@ describe(`persistedCollectionOptions`, () => { // Earlier laws covered external-sync commit classification and a // successful follower mutation acknowledgement. They never varied a // serialized applyLocalMutations failure by route. The elected-leader - // and non-durability rows are hostile controls for topology parity and + // and non-durability rows are mutant controls for topology parity and // for avoiding an over-broad fail-stop classifier. if (expectedDurabilityError) { expect(rejection).toMatchObject({ @@ -1692,7 +1692,7 @@ describe(`persistedCollectionOptions`, () => { await vi.waitFor(() => expect(sourceLoads).toBe(2)) }) - // This deliberately wrong global-eviction message is a hostile control for + // This deliberately wrong global-eviction message is a mutant control for // the two-run Browser generation-routing oracle. A global full reload makes // the warm run lose a valid source row, so it cannot implement cache // retirement. This driver checks that the public-row comparison detects diff --git a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts new file mode 100644 index 0000000000..1bdc8ff1ff --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts @@ -0,0 +1,361 @@ +import { + createCollection, + createLiveQueryCollection, + createTransaction, + whenSyncAccepted, +} from '@tanstack/db' +import { + checkOptimisticHistory, + optimisticHistoryCalibrations, + optimisticHistoryDriver, + optimisticHistoryModel, + optimisticHistoryReach, + optimisticHistoryStates, + resolveOptimisticHistory, + wrappingSteps, + wrappingSyncHistory, +} from '../../db/tests/optimistic-history-oracle' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleProperty } from '../../db/tests/oracle-property' +import { persistedCollectionOptions } from '../src' +import type { CollectionConfig } from '@tanstack/db' +import type { + HistoryRow, + OptimisticStep, + WrappingSyncPath, +} from '../../db/tests/optimistic-history-oracle' +import type { PersistedTx, PersistenceAdapter } from '../src' + +/** + * # Persisted Collections keep the optimistic-history laws + * + * The optimistic-history oracle states which rows optimistic and sync + * histories expose. Persistence is one production path for those laws: a + * wrapping sync between the source and the Collection. This property runs the + * oracle's model, check, and calibrations through `persistedCollectionOptions` + * with an in-memory adapter, over the wrapping-sync grammar. + * + * The persisted wrapper stores source transactions one at a time. The + * `hold-store` step holds the next stored source transaction; source + * transactions committed after it reach the Collection only after + * `release-store`. That is the model's `deferred` state. The two laws under + * test are the oracle's own: an optimistic mutation is visible in the tick + * that makes it, and a settled optimistic row stays over the synced rows while + * a committed sync transaction for its key has not applied, deferred or not. + * + * `abort-deferred` aborts one deferred transaction's signal. It never applies: + * the wrapper discards it when its turn comes, and every settled row it alone + * held drops in that publication. The model fixes that drop at the turn; an + * implementation that dropped the hold at abort time would also be legal, and + * this wrapping-sync path would reject it. + * + * Reach: two constructed histories make the wrapping-sync path's own states required on every run. A + * hold across a drain (`deferred-hold`) comes after a generated history, so + * the hold meets whatever that history left; an abort that releases a held + * row (`abort-release`) comes first and releases the store itself, because a + * later settle could pick a transaction the generated part left active. + * + * Grammar limits: a handler cannot await acceptance while a hold is + * outstanding, because acceptance follows the held durable step; the grammar + * clears `awaitReceipt` there, and its unmapper admits only such histories. + * The schema, partial-update, and reentrant grammars stay core-only. The adapter + * is a Map, so SQLite timing and coordinator messages are outside this wrapping-sync path. + */ +function createHoldingAdapter() { + const rows = new Map() + let holdNext = false + let release: (() => void) | undefined + let latest = { term: 0, seq: 0, rowVersion: 0 } + const adapter: PersistenceAdapter = { + loadSubset: () => + Promise.resolve( + Array.from(rows, ([key, value]) => ({ key, value: { ...value } })), + ), + loadResumeSnapshot: () => + Promise.resolve({ + rows: [], + keySet: { status: `consistent` }, + collectionMetadata: [], + latestTerm: latest.term, + latestSeq: latest.seq, + latestRowVersion: latest.rowVersion, + resetEpoch: 0, + }), + loadCollectionMetadata: () => Promise.resolve([]), + applyCommittedTx: async (_target, tx: PersistedTx) => { + if (holdNext && (tx.truncate || tx.mutations.length > 0)) { + holdNext = false + await new Promise((resolve) => { + release = resolve + }) + } + if (tx.truncate) rows.clear() + for (const mutation of tx.mutations) { + if (mutation.type === `delete`) rows.delete(mutation.key) + else + rows.set(mutation.key, { + ...rows.get(mutation.key), + ...mutation.value, + } as HistoryRow) + } + latest = { term: tx.term, seq: tx.seq, rowVersion: tx.rowVersion } + }, + ensureIndex: () => Promise.resolve(), + } + return { + adapter, + rows, + holdNextStore: () => { + holdNext = true + }, + releaseStore: () => { + holdNext = false + release?.() + release = undefined + }, + } +} + +let productionPathRun = 0 + +function persistedProductionPath(): WrappingSyncPath & { + rows: Map +} { + const store = createHoldingAdapter() + const id = `persisted-optimistic-history-${++productionPathRun}` + return { + db: { + createCollection, + createLiveQueryCollection, + createTransaction, + whenSyncAccepted, + }, + rows: store.rows, + wrap: (config) => + persistedCollectionOptions({ + ...(config as CollectionConfig), + id, + persistence: { adapter: store.adapter }, + }) as unknown as CollectionConfig, + holdNextStore: store.holdNextStore, + releaseStore: store.releaseStore, + // The wrapper's turns are promise and timer continuations. + settle: async () => { + for (let turn = 0; turn < 10; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + }, + } +} + +// A handler commits its server row and returns without awaiting acceptance; +// the source's acknowledgement came at commit. Each edit's fields are its +// whole server row, as the grammar's acknowledged edits are. The insert's stored row holds +// the store, so the update's server row is deferred when both settle. +const insertThenUpdateBehindHeldStore: Array = [ + { type: `hold-store` }, + { + type: `edit`, + key: 1, + fields: { a: 1, b: 0, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [{ id: 1, a: 1, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + }, + { + type: `edit`, + key: 1, + fields: { a: 2, b: 0, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [{ id: 1, a: 2, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `release-store` }, +] + +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +/** A wrapping history's generated body, without its settle-and-observe tail. */ +const bodyOf = (history: { steps: Array }) => + history.steps.slice(0, history.steps.length - wrappingSteps([]).length) +const row = (id: number, a: number): HistoryRow => ({ id, a, b: 0, c: 0 }) + +// A hold across a drain: the store holds a truncating source transaction, an +// optimistic edit commits its server row in its handler, and that row waits +// behind the held store when the tail settles. +const holdAcrossDrain: Array = [ + { type: `hold-store` }, + { type: `close`, commit: false }, + { + type: `sync`, + rows: [ + { id: 3, a: 1, b: -2, c: 0 }, + { id: 1, a: -2, b: 1, c: 0 }, + ], + deletes: [1, 3], + truncate: true, + copies: 1, + }, + { + type: `edit`, + key: 3, + fields: { a: 1 }, + optimistic: true, + inHandler: undefined, + }, + { + type: `edit`, + key: 1, + fields: { a: 2, b: -1, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [{ id: 1, a: 2, b: -1, c: 0 }], + truncate: false, + copies: 1, + }, + }, +] + +// An abort that releases a held row: the store holds key 2's source +// transaction, key 1's handler commits its server row behind it, the edit +// settles (its row is held by that deferred row), and aborting the deferred +// row releases the hold at its turn. The edit's value differs from the +// initial row 1, so the edit always changes the row. +const abortReleasingHold = ( + initial: Array, +): Array => { + const a = initial.find((entry) => entry.id === 1)?.a === 1 ? 2 : 1 + return [ + { type: `hold-store` }, + { + type: `sync`, + rows: [row(2, 1)], + deletes: [], + truncate: false, + copies: 1, + }, + { + type: `edit`, + key: 1, + fields: { a, b: 0, c: 0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [row(1, a)], + truncate: false, + copies: 1, + }, + }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `abort-deferred`, slot: 0 }, + { type: `release-store` }, + ] +} + +declareOracle({ + owns: [`src/persisted.ts`], + check: [checkOptimisticHistory], + model: [optimisticHistoryModel], +}) + +oracleProperty({ + id: `sqlite-persistence.optimistic-history`, + law: { + statement: `A persisted Collection exposes the rows, publications, request outcomes, receipts, and tracked transactions that the optimistic-history model owes at every step, including while a durable store step is held and after a deferred source transaction aborts.`, + authority: `packages/db/tests/optimistic-history-oracle.ts header (the optimistic and sync history contract, held rows behind a wrapping sync)`, + limits: `An in-memory adapter, not SQLite timing or coordinator messages; no awaited acceptance during a hold; no schema, partial-update, or reentrant grammar.`, + owner: `Optimistic state`, + }, + histories: { + arbitrary: wrappingSyncHistory, + states: [`deferred-hold`, `abort-release`], + measured: optimisticHistoryStates.filter( + (state) => state !== `deferred-hold` && state !== `abort-release`, + ), + runs: 40, + fixedSeeds: [22012], + constructed: [ + { + name: `a hold across a drain`, + enters: [`deferred-hold`], + build: (generated) => ({ + initial: generated.initial, + steps: wrappingSteps([ + ...bodyOf(generated).slice(0, 24 - holdAcrossDrain.length), + ...holdAcrossDrain, + ]), + }), + }, + { + name: `an abort that releases a held row`, + enters: [`abort-release`], + build: (generated) => ({ + initial: generated.initial, + steps: wrappingSteps( + [ + ...abortReleasingHold(generated.initial), + ...bodyOf(generated), + ].slice(0, 24), + ), + }), + }, + ], + fixed: [ + { + // The same history with partial optimistic fields, written as core + // edits whose handler batches do not await acceptance. + name: `a partial insert and update stay over their server rows behind a held store`, + history: { + initial: [], + steps: wrappingSteps( + insertThenUpdateBehindHeldStore.map((step) => + step.type === `edit` && step.inHandler + ? { + ...step, + fields: { a: step.fields.a }, + inHandler: { + ...step.inHandler, + deletes: [], + awaitReceipt: false, + }, + } + : step, + ), + ), + }, + }, + { + name: `a settled update stays over its insert while the update's server row waits behind a held store`, + history: { + initial: [], + steps: wrappingSteps(insertThenUpdateBehindHeldStore), + }, + }, + ], + }, + resolve: resolveOptimisticHistory, + model: optimisticHistoryModel, + reached: optimisticHistoryReach, + // Each history gets its own adapter and Collection id. + driver: (plan, observe) => + optimisticHistoryDriver({ wrapping: persistedProductionPath() })( + plan, + observe, + ), + check: checkOptimisticHistory, + calibrations: optimisticHistoryCalibrations, + timeout: 60_000, +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts deleted file mode 100644 index 6f57096fb2..0000000000 --- a/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { fc, test as fcTest } from '@fast-check/vitest' -import { expect, it } from 'vitest' -import { - createCollection, - createLiveQueryCollection, - createTransaction, - whenSyncAccepted, -} from '@tanstack/db' -import { - runOptimisticHistory, - wrappingSyncHistory, -} from '../../db/tests/optimistic-history-oracle' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' -import { persistedCollectionOptions } from '../src' -import type { CollectionConfig } from '@tanstack/db' -import type { - HistoryRow, - OptimisticStep, - WrappingSyncLane, -} from '../../db/tests/optimistic-history-oracle' -import type { PersistedTx, PersistenceAdapter } from '../src' - -/** - * The optimistic-history oracle states which rows optimistic and sync - * histories expose. Persistence is one production path for those laws: a - * wrapping sync between the source and the Collection. This file runs the - * oracle's model, grammar, and comparisons through `persistedCollectionOptions` - * with an in-memory adapter. - * - * The persisted wrapper stores source transactions one at a time. The - * `hold-store` step holds the next stored source transaction; source - * transactions committed after it reach the Collection only after - * `release-store`. That is the model's `deferred` state. The two laws under - * test are the oracle's own: an optimistic mutation is visible in the tick - * that makes it, and a settled optimistic row stays over the synced rows while - * a committed sync transaction for its key has not applied, deferred or not. - * - * `abort-deferred` aborts one deferred transaction's signal. It never applies: - * the wrapper discards it when its turn comes, and every settled row it alone - * held drops in that publication. The model fixes that drop at the turn; an - * implementation that dropped the hold at abort time would also be legal, and - * this lane would reject it. The last test checks that the fixed campaign - * reached a hold across a drain and a hold released by an abort. - * - * Grammar limits: a handler cannot await acceptance while a hold is - * outstanding, because acceptance follows the held durable step. The schema, - * partial-update, and reentrant lanes stay core-only. The adapter is a Map, - * so SQLite timing and coordinator messages are outside this lane. - */ -function createHoldingAdapter() { - const rows = new Map() - let holdNext = false - let release: (() => void) | undefined - let latest = { term: 0, seq: 0, rowVersion: 0 } - const adapter: PersistenceAdapter = { - loadSubset: () => - Promise.resolve( - Array.from(rows, ([key, value]) => ({ key, value: { ...value } })), - ), - loadResumeSnapshot: () => - Promise.resolve({ - rows: [], - keySet: { status: `consistent` }, - collectionMetadata: [], - latestTerm: latest.term, - latestSeq: latest.seq, - latestRowVersion: latest.rowVersion, - resetEpoch: 0, - }), - loadCollectionMetadata: () => Promise.resolve([]), - applyCommittedTx: async (_target, tx: PersistedTx) => { - if (holdNext && (tx.truncate || tx.mutations.length > 0)) { - holdNext = false - await new Promise((resolve) => { - release = resolve - }) - } - if (tx.truncate) rows.clear() - for (const mutation of tx.mutations) { - if (mutation.type === `delete`) rows.delete(mutation.key) - else - rows.set(mutation.key, { - ...rows.get(mutation.key), - ...mutation.value, - } as HistoryRow) - } - latest = { term: tx.term, seq: tx.seq, rowVersion: tx.rowVersion } - }, - ensureIndex: () => Promise.resolve(), - } - return { - adapter, - rows, - holdNextStore: () => { - holdNext = true - }, - releaseStore: () => { - holdNext = false - release?.() - release = undefined - }, - } -} - -let laneRun = 0 - -function persistedLane(): WrappingSyncLane & { - rows: Map -} { - const store = createHoldingAdapter() - const id = `persisted-optimistic-history-${++laneRun}` - return { - db: { - createCollection, - createLiveQueryCollection, - createTransaction, - whenSyncAccepted, - }, - rows: store.rows, - wrap: (config) => - persistedCollectionOptions({ - ...(config as CollectionConfig), - id, - persistence: { adapter: store.adapter }, - }) as unknown as CollectionConfig, - holdNextStore: store.holdNextStore, - releaseStore: store.releaseStore, - // The wrapper's turns are promise and timer continuations. - settle: async () => { - for (let turn = 0; turn < 10; turn++) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - }, - } -} - -// Each campaign must reach the states its laws are about, not only pass. -type CampaignReach = { - runs: number - deferredHolds: number - abortReleases: number -} -const reach: Record<`fixed` | `random`, CampaignReach> = { - fixed: { runs: 0, deferredHolds: 0, abortReleases: 0 }, - random: { runs: 0, deferredHolds: 0, abortReleases: 0 }, -} - -async function runPersistedHistory( - { - initial, - steps, - }: { - initial: Array - steps: Array - }, - campaign?: CampaignReach, -) { - const lane = persistedLane() - const counts = await runOptimisticHistory(initial, steps, undefined, { - wrapping: lane, - }) - if (!campaign) return - campaign.runs++ - campaign.deferredHolds += counts.deferredHolds - campaign.abortReleases += counts.abortReleases -} - -// A handler commits its server row and returns without awaiting acceptance; -// the source's acknowledgement came at commit. The insert's stored row holds -// the store, so the update's server row is deferred when both settle. -const insertThenUpdateBehindHeldStore: Array = [ - { type: `hold-store` }, - { - type: `edit`, - key: 1, - fields: { a: 1 }, - optimistic: true, - inHandler: { - type: `sync`, - rows: [{ id: 1, a: 1, b: 0, c: 0 }], - truncate: false, - copies: 1, - }, - }, - { - type: `edit`, - key: 1, - fields: { a: 2 }, - optimistic: true, - inHandler: { - type: `sync`, - rows: [{ id: 1, a: 2, b: 0, c: 0 }], - truncate: false, - copies: 1, - }, - }, - { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `release-store` }, -] - -it(`keeps a settled update over its insert while the update's server row waits behind a held store`, async () => { - await runPersistedHistory({ - initial: [], - steps: insertThenUpdateBehindHeldStore, - }) -}) - -it(`reconstructs the persisted grammar and excludes awaited acceptance during a hold`, () => { - const samples = fc.sample(wrappingSyncHistory, { - seed: 22_011, - numRuns: 200, - }) - const steps = samples.flatMap((sample) => sample.steps) - expect(steps.some((step) => step.type === `hold-store`)).toBe(true) - expect(steps.some((step) => step.type === `release-store`)).toBe(true) - for (const sample of samples) { - let holding = false - for (const step of sample.steps) { - if (step.type === `hold-store`) holding = true - if (step.type === `release-store`) holding = false - if (holding && (step.type === `edit` || step.type === `delete`)) - expect(step.inHandler?.awaitReceipt ?? false).toBe(false) - } - } -}) - -fcTest.prop([wrappingSyncHistory], { - numRuns: oracleRuns(40), - seed: 22_012, -})( - `persisted Collections match optimistic histories with a fixed seed`, - (history) => runPersistedHistory(history, reach.fixed), - 60_000, -) -fcTest.prop( - [wrappingSyncHistory], - oraclePropertyOptions(40, `sqlite-persistence.optimistic-history`), -)( - `persisted Collections match optimistic histories with a random or replayed seed`, - (history) => runPersistedHistory(history, reach.random), - 60_000, -) - -// The fixed campaign owes this reach; its seed is stable. A random seed can -// miss a rare state, so its reach is reported, not required. A replay runs -// one history. -it(`persisted fixed campaign reaches deferred holds and holds released by an abort`, () => { - if (process.env.TANSTACK_DB_ORACLE_SEED !== undefined) return - if (reach.fixed.runs === 0) return - expect(reach.fixed.deferredHolds, `fixed deferred holds`).toBeGreaterThan(0) - expect(reach.fixed.abortReleases, `fixed abort releases`).toBeGreaterThan(0) - console.info(`random campaign reach`, reach.random) -}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts index 2608a7fe9c..a2b2487461 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts @@ -30,9 +30,12 @@ import fc from 'fast-check' import ts from 'typescript' import { it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../../db/tests/oracle-config.js' @@ -395,7 +398,7 @@ function collectionSource(shape: Shape, cell: Cell): string { * A supplemental transparency relation preserves the full payload across the * wrapper; QueryObserverResult internals have no independent model here. * This single model replaces standalone fixed refetch/nonexistent-method calls. - * Exact namespace equality pins the current API. An intentional documented + * Exact namespace equality checks the current API. An intentional documented * utility addition must update this model; closure does not prohibit additions. */ function querySource(shape: Shape): string { @@ -620,7 +623,7 @@ const utilityProperties = { * Every generated history crosses the structurally required forms. Reach is * recorded only after the generated consumer passes compilation. A replay uses * the shared seed/path/property interface: it runs the random campaign alone, - * without the pinned shapes. + * without the fixed shapes. * Fixed and fresh-seed campaigns use the identical grammar, budget, and check. */ export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { @@ -792,8 +795,9 @@ export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { }) const parameters = campaign === `fixed` - ? { numRuns: oracleRuns(12), seed: 2259 } + ? oracleFixedSeed(2259, { numRuns: oracleRuns(12) }) : oraclePropertyOptions(12, propertyName) + oracleRoute(oracleRouteOf(parameters)) await fc.assert(property, parameters) // Replays use the same check but do not inflate generated reach. An // explicitly requested seed/path goes directly to its generated history. @@ -874,6 +878,11 @@ export function registerPersistedUtilityAdmissionOracle( `inherited`, `schema-collision`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2259, { numRuns: oracleRuns(12) }) + : oraclePropertyOptions(12, propertyName) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(utilityAdmissionArbitrary, ({ token, extraNames, names }) => { reach.beginHistory() @@ -1028,9 +1037,7 @@ export function registerPersistedUtilityAdmissionOracle( } } }), - campaign === `fixed` - ? { numRuns: oracleRuns(12), seed: 2259 } - : oraclePropertyOptions(12, propertyName), + parameters, ) reach.assertReached() console.info( 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 04a1ad80d7..0f2a3396b2 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -19,9 +19,12 @@ import { persistedCollectionOptions, } from '../src' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -1287,7 +1290,8 @@ function generatedPersistenceGrammarSamples(): ReadonlyArray<{ if (match === undefined) throw new Error(`missing grammar: ${property}`) return match } - const sampleOptions = (seed: number) => ({ seed, numRuns: oracleRuns(4) }) + const sampleOptions = (seed: number) => + oracleFixedSeed(seed, { numRuns: oracleRuns(4) }) return [ { grammar: grammar(`sqlite-persistence.source-fifo-order`), @@ -1414,14 +1418,21 @@ function registerGeneratedPersistenceProperty(options: { run: (value: Value) => Promise }): void { const { property, title, arbitrary, fixedSeed, run } = options - oracleReplayTest(fcTest).prop([arbitrary], { - seed: fixedSeed, - numRuns: oracleRuns(4), - })(`${title} (fixed)`, run) - oracleReplayTest(fcTest, property).prop( + oracleReplayTest(fcTest).prop( [arbitrary], - oraclePropertyOptions(4, property), - )(`${title} (random)`, run) + oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(4) }), + )(`${title} (fixed)`, (...args: Parameters) => { + oracleRoute(`fixed seed`) + return run(...args) + }) + const randomCampaign = oraclePropertyOptions(4, property) + oracleReplayTest(fcTest, property).prop([arbitrary], randomCampaign)( + `${title} (random)`, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(randomCampaign)) + return run(...args) + }, + ) } // The durability drivers run generated histories through the laws in this @@ -1429,6 +1440,11 @@ function registerGeneratedPersistenceProperty(options: { // authored inputs; the check drivers compare production observations with // them, and the grammar reaches the generated histories. declareOracle({ + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], check: [ runGeneratedSourceFifoOrderingHistory, runGeneratedAbortGraphHistory, @@ -1478,6 +1494,7 @@ const describeDurabilityOracles = oracleReplayTest( describeDurabilityOracles(`generated persistence durability oracles`, () => { if (!oracleReplayRequested()) { it(`reconstructs the full grammar and rejects ablated, out-of-range, and foreign histories`, () => { + oracleRoute(`calibration`) for (const { grammar, histories, @@ -1632,6 +1649,7 @@ describeDurabilityOracles(`generated persistence durability oracles`, () => { ), }, ])(`rejects named wrong answer: $name`, ({ reject }) => { + oracleRoute(`calibration`) expect(reject).toThrow() }) } @@ -1681,6 +1699,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { it.each([`during`, `straddles`, `after`] as const)( `durably settles a source transaction that commits %s persisted hydration`, async (position) => { + oracleRoute(`case`) const hydrationEntered = createEventGate() const hydration = createEventGate() const adapter = createRecordingAdapter() @@ -1839,6 +1858,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ) it(`does not let a later same-key transaction overtake a hydration-straddling commit`, async () => { + oracleRoute(`fixed history`) const hydrationEntered = createEventGate() const hydration = createEventGate() const adapter = createRecordingAdapter() @@ -1939,6 +1959,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }) it(`settles an aborted hydration-straddling commit without applying it`, async () => { + oracleRoute(`fixed history`) const hydrationEntered = createEventGate() const hydration = createEventGate() const adapter = createRecordingAdapter() @@ -2012,6 +2033,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { }) it(`append-only transaction judgment rejects omission and reordering`, () => { + oracleRoute(`calibration`) const events: Array = [ { type: `begin`, transactionId: `first` }, { @@ -2042,6 +2064,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { it.each([`startup`, `resume`, `reset`] as const)( `keeps the %s hydration ready after a buffered pre-application abort`, async (entry) => { + oracleRoute(`case`) const adapter = createRecordingAdapter() const hydrationEntered = createEventGate() const releaseHydration = createEventGate() @@ -2250,6 +2273,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { )( `publishes committed source $type after $hydrationCount queued hydrations (reserveCommitTurn=$reserveCommitTurn, successor=$hasSuccessor)`, async ({ type, hydrationCount, reserveCommitTurn, hasSuccessor }) => { + oracleRoute(`case`) // https://github.com/TanStack/db/issues/2036 const adapter = createRecordingAdapter([ { id: `shared`, title: `persisted`, detail: `retained baseline` }, @@ -2474,6 +2498,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { )( `publishes exact durable row shape within $route ($shape)`, async ({ route, shape }) => { + oracleRoute(`case`) const ordinary: Todo = shape === `add undefined` ? { id: `ordinary`, title: `Old` } @@ -2697,6 +2722,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { )( `publishes the durable result of a targeted partial $transition with $route`, async ({ route, transition }) => { + oracleRoute(`case`) const old: Todo = { id: `ordinary`, title: transition === `enter` ? `Other` : `Keep`, @@ -2900,6 +2926,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { )( `replaces a hydrated seed and metadata with a peer's targeted $mutation with $route ($metadataAction)`, async ({ route, mutation, metadataAction }) => { + oracleRoute(`case`) const ordinary = route === `active filter` ? { id: `ordinary`, title: `Keep`, detail: `old` } @@ -3175,6 +3202,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { )( `follows durable same-key order for targeted $sequence with $route`, async ({ route, sequence }) => { + oracleRoute(`case`) const old = { id: `row`, title: `Old`, detail: `old detail` } const fresh = { id: old.id, title: `Fresh` } const detailPatch = { id: old.id, detail: `new detail` } @@ -3476,19 +3504,26 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } } - fcTest.prop([retainedDemandHistory], { + const retainedDemandHistoryFixedSeed = oracleFixedSeed(186001, { numRuns: oracleRuns(50), - seed: 186001, - })( + }) + fcTest.prop([retainedDemandHistory], retainedDemandHistoryFixedSeed)( `matches the retained exact-demand model with a fixed seed`, - runRetainedDemandHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(retainedDemandHistoryFixedSeed)) + return runRetainedDemandHistory(...args) + }, ) - fcTest.prop( - [retainedDemandHistory], - oraclePropertyOptions(50, `persistence.retained-demand`), - )( + const retainedDemandHistoryCampaign = oraclePropertyOptions( + 50, + `persistence.retained-demand`, + ) + fcTest.prop([retainedDemandHistory], retainedDemandHistoryCampaign)( `matches the retained exact-demand model with a random or replayed seed`, - runRetainedDemandHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(retainedDemandHistoryCampaign)) + return runRetainedDemandHistory(...args) + }, ) /** @@ -3529,8 +3564,8 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { * ensure log at quiescence. The coordinator is a controlled double of an * elected owner; it does not prove the browser transport. * - * Known gap (#2139): production does not end a rotated follower's scoped - * recovery, so this lane's cells are pinned as failing until it does. + * Known limit (#2139): production does not end a rotated follower's scoped + * recovery, so this production path's cells are fixed histories that fail until it does. */ type FollowerGenerationNoticeHistory = { ownerCommit: `before-rotation` | `after-rotation` @@ -3838,7 +3873,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } } - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each([ { ownerCommit: `before-rotation`, later: `none` }, @@ -3847,11 +3882,16 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { { ownerCommit: `before-rotation`, later: `peer` }, ] as const)( `publishes an elected owner's rows to a follower after rotation: commit $ownerCommit, later $later`, - (history) => - assertFollowerGenerationNoticeHistory({ ...history, ownerRead: `ok` }), + (history) => { + oracleRoute(`case`) + return assertFollowerGenerationNoticeHistory({ + ...history, + ownerRead: `ok`, + }) + }, ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each( ([`gap`, `reconciliation`] as const).flatMap((noticeShape) => @@ -3865,32 +3905,44 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), )( `publishes an elected owner's rows after rotation when its notice is a $noticeShape: commit $ownerCommit, later $later`, - (history) => - assertFollowerGenerationNoticeHistory({ ...history, ownerRead: `ok` }), + (history) => { + oracleRoute(`case`) + return assertFollowerGenerationNoticeHistory({ + ...history, + ownerRead: `ok`, + }) + }, ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each([ { ownerCommit: `before-rotation`, later: `owner` }, { ownerCommit: `after-rotation`, later: `owner` }, ] as const)( `fails a follower when its elected owner's update cannot be read after rotation: commit $ownerCommit`, - (history) => - assertFollowerGenerationNoticeHistory({ ...history, ownerRead: `fails` }), + (history) => { + oracleRoute(`case`) + return assertFollowerGenerationNoticeHistory({ + ...history, + ownerRead: `fails`, + }) + }, ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails( `ignores a follower read failure when rotation starts while the read is in flight`, - () => - assertFollowerGenerationNoticeHistory({ + () => { + oracleRoute(`fixed history`) + return assertFollowerGenerationNoticeHistory({ ownerCommit: `before-rotation`, later: `none`, ownerRead: `ok`, readFailsAsRotationStarts: true, - }), + }) + }, ) const followerGenerationNoticeHistory = fc.record({ @@ -3907,21 +3959,24 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { `fails`, ), }) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // 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 + // generated histories pass today, so a random draw cannot be a fixed history that + // fails; this fixed seed records the limit. The fix for #2139 adds the random // and replayed campaign back. it.fails( `checks generated follower generation-notice histories (fixed)`, - () => - fc.assert( + () => { + const parameters = oracleFixedSeed(2128, { numRuns: oracleRuns(12) }) + oracleRoute(oracleRouteOf(parameters)) + return fc.assert( fc.asyncProperty( followerGenerationNoticeHistory, assertFollowerGenerationNoticeHistory, ), - { seed: 2128, numRuns: oracleRuns(12) }, - ), + parameters, + ) + }, ) const expectedFollowerRemoteDemand = ( @@ -4080,9 +4135,12 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), )( `settles a follower's remote demand with the owner's applied rows: $placement broadcast, apply $apply, $consumer`, - assertFollowerRemoteDemandHistory, + (...args: Parameters) => { + oracleRoute(`case`) + return assertFollowerRemoteDemandHistory(...args) + }, ) - // Known gap: see #2139. A rotated follower stays in scoped recovery, so + // Known limit: see #2139. A rotated follower stays in scoped recovery, so // the owner's rows in the new generation never become visible. it.fails.each( followerRemoteDemandHistories.filter( @@ -4090,7 +4148,10 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), )( `settles a follower's remote demand with the owner's applied rows: $placement broadcast, apply $apply, $consumer`, - assertFollowerRemoteDemandHistory, + (...args: Parameters) => { + oracleRoute(`calibration`) + return assertFollowerRemoteDemandHistory(...args) + }, ) // Scoped recovery means durable rows cannot replace source rows. Owner @@ -4136,9 +4197,9 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { * recovery in place, and a newer rotation supersedes an older one's * reacquisition. * - * Known gap (#2139): production does not yet end scoped recovery after a + * Known limit (#2139): production does not yet end scoped recovery after a * rotation, so the histories in which recovery should end, or in which a - * new demand waits for it, are pinned as failing. + * new demand waits for it, are fixed histories that fail. * * Limits. A failed reacquisition is not retried; the follower stays in * scoped recovery until a later recovery rotates again. @@ -4533,67 +4594,71 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), ), ) - // Known gap: see #2139. Recovery never ends today, so the histories in + // Known limit: see #2139. Recovery never ends today, so the histories in // which it should end, or in which a new demand must wait for it, fail. - const reacquisitionGap = ({ + const reacquisitionLimit = ({ fails, newDemand, }: RotatedReacquisitionHistory) => newDemand === true || fails === `none` || fails === `released` it.each( rotatedReacquisitionHistories.filter( - (history) => !reacquisitionGap(history), + (history) => !reacquisitionLimit(history), ), )( `ends a follower's scoped recovery only after every reacquisition succeeds: order $order, notice between $noticeBetween, fails $fails, new demand $newDemand`, - assertRotatedReacquisition, + (...args: Parameters) => { + oracleRoute(`case`) + return assertRotatedReacquisition(...args) + }, 10_000, ) - it.fails.each(rotatedReacquisitionHistories.filter(reacquisitionGap))( + it.fails.each(rotatedReacquisitionHistories.filter(reacquisitionLimit))( `ends a follower's scoped recovery only after every reacquisition succeeds: order $order, notice between $noticeBetween, fails $fails, new demand $newDemand`, - assertRotatedReacquisition, - 10_000, - ) - it( - `releases held notices when a second rotation supersedes reacquisition as leader`, - () => - assertRotatedReacquisition({ - order: [`a`, `b`], - noticeBetween: false, - fails: `none`, - supersededAsLeader: true, - }), + (...args: Parameters) => { + oracleRoute(`calibration`) + return assertRotatedReacquisition(...args) + }, 10_000, ) + it(`releases held notices when a second rotation supersedes reacquisition as leader`, () => { + oracleRoute(`fixed history`) + return assertRotatedReacquisition({ + order: [`a`, `b`], + noticeBetween: false, + fails: `none`, + supersededAsLeader: true, + }) + }, 10_000) // Two hydrations that both find the claim lost queue work while the // follower reacquires. Their flushes must leave the held notices alone; // otherwise each notice refreshes the scoped demand and the follower spins. - it( - `holds a rotated follower's notices when two reads find the claim lost: fails 'a'`, - () => - assertRotatedReacquisition({ - order: [`a`, `b`], - noticeBetween: false, - fails: `a`, - trigger: `claim-lost-during-reads`, - }), - 10_000, - ) - // Known gap: see #2139. Recovery never ends, so the rows stay hidden. + it(`holds a rotated follower's notices when two reads find the claim lost: fails 'a'`, () => { + oracleRoute(`fixed history`) + return assertRotatedReacquisition({ + order: [`a`, `b`], + noticeBetween: false, + fails: `a`, + trigger: `claim-lost-during-reads`, + }) + }, 10_000) + // Known limit: see #2139. Recovery never ends, so the rows stay hidden. it.fails( `holds a rotated follower's notices when two reads find the claim lost: fails 'none'`, - () => - assertRotatedReacquisition({ + () => { + oracleRoute(`calibration`) + return assertRotatedReacquisition({ order: [`a`, `b`], noticeBetween: false, fails: `none`, trigger: `claim-lost-during-reads`, - }), + }) + }, 10_000, ) const followerRemoteDemandHistory = fc.record({ - // Known gap: see #2139. The rotated placement is pinned above. + // Known limit: see #2139. The rotated placement is a fixed history above. placement: fc.constantFrom( `direct`, `queued`, @@ -4605,17 +4670,25 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { `load`, ), }) + const followerRemoteDemandHistoryCampaign = oraclePropertyOptions( + 12, + `persistence.follower-remote-demand`, + ) fcTest.prop( [followerRemoteDemandHistory], - oraclePropertyOptions(12, `persistence.follower-remote-demand`), + followerRemoteDemandHistoryCampaign, )( `checks generated follower remote-demand histories (random or replayed)`, - assertFollowerRemoteDemandHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(followerRemoteDemandHistoryCampaign)) + return assertFollowerRemoteDemandHistory(...args) + }, ) it.each(persistedKeySetEvidenceStatuses.map((status) => ({ status })))( `keeps on-demand rows independent from $status baseline evidence`, async ({ status }) => { + oracleRoute(`case`) const adapter = createRecordingAdapter([ { id: `on-demand`, title: `On-demand row` }, ]) @@ -4677,6 +4750,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { it.each(persistedKeySetEvidenceStatuses.map((status) => ({ status })))( `keeps force reload rows independent from $status startup evidence`, async ({ status }) => { + oracleRoute(`case`) const adapter = createRecordingAdapter([ { id: `on-demand`, title: `On-demand row` }, ]) @@ -4717,6 +4791,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ) it(`rejects an evidence-coupled on-demand hydration mutant`, () => { + oracleRoute(`calibration`) expect(() => expectOnDemandEvidenceLaw({ status: `incompatible`, @@ -4736,6 +4811,11 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { it(`keeps generated crossed-leadership index RPC histories outside local hydration scopes`, async () => { let run = 0 + const campaignParameters = oracleFixedSeed(1868, { + numRuns: 8, + endOnFailure: true, + }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -4838,7 +4918,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } }, ), - { seed: 1868, numRuns: 8, endOnFailure: true }, + campaignParameters, ) }) @@ -4849,6 +4929,7 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { itUnlessAnyOracleReplay.each(hydrationScopeModes)( `replays a buffered source receipt without blocking its persisting predecessor (%s scope)`, async (scopeMode) => { + oracleRoute(`case`) const adapter = createRecordingAdapter() const hydrateLoadEntered = createDeferred() const allowHydrateLoad = createDeferred() @@ -6525,7 +6606,10 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ), )( `preserves sync history over persisted reads ($syncMode, $route, $operation, truncate=$truncateReplay, timing=$commitTiming)`, - runPersistedReadHistory, + (...args: Parameters) => { + oracleRoute(`case`) + return runPersistedReadHistory(...args) + }, ) // Reservation cross: every route, reserving timing, ending, and follow-up, @@ -6666,11 +6750,17 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { ).flat() it.each(reReserveHistories)( `keeps a follower's load behind a second reservation (delays=$reReserveDelays)`, - runPersistedReadHistory, + (...args: Parameters) => { + oracleRoute(`case`) + return runPersistedReadHistory(...args) + }, ) it.each(reservationHistories)( `keeps reserved commits and their neighbours lawful ($syncMode, $route, timing=$commitTiming, ending=$ending, follow-up=$followUp)`, - runPersistedReadHistory, + (...args: Parameters) => { + oracleRoute(`case`) + return runPersistedReadHistory(...args) + }, ) // The model predicts every observation from the history and its input @@ -6787,8 +6877,9 @@ function expectedDurableHydrationTransactions( // replay from this shared file. These bounded cases exclude every explicit replay. // Terminal local persistence failures crash the Collection after readiness, // following the IndexedDB closure precedent. Upstream (wrapped sync adapter) -// failures after readiness stay health metadata; this lane holds both. +// failures after readiness stay health metadata; this block holds both. describeUnlessAnyOracleReplay(`hydration source durability routing`, () => { + oracleRoute(`fixed history`) const first = { id: `shared`, title: `first source value` } const second = { id: `shared`, title: `last source value` } const other = { id: `other`, title: `independent source value` } @@ -6912,6 +7003,7 @@ describeUnlessAnyOracleReplay(`hydration source durability routing`, () => { it.each(cases)( `$phase scoped=$scoped baseline=$baselineCount $name`, async ({ phase, scoped, baselineCount, transactions, failAt }) => { + oracleRoute(`case`) // The shared key makes source precedence over persisted data observable. const baseline = baselineCount ? [{ id: `shared`, title: `old persisted value` }] @@ -7348,19 +7440,26 @@ declareOracle({ }) describe(`persistedCollectionOptions`, () => { - fcTest.prop([generationNoticeHistory], { - seed: 2069, + const generationNoticeHistoryFixedSeed = oracleFixedSeed(2069, { numRuns: oracleRuns(24), - })( + }) + fcTest.prop([generationNoticeHistory], generationNoticeHistoryFixedSeed)( `checks generated cache-generation notice histories (fixed)`, - assertGenerationNoticeHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(generationNoticeHistoryFixedSeed)) + return assertGenerationNoticeHistory(...args) + }, ) - fcTest.prop( - [generationNoticeHistory], - oraclePropertyOptions(24, `persistence.generation-notice`), - )( + const generationNoticeHistoryCampaign = oraclePropertyOptions( + 24, + `persistence.generation-notice`, + ) + fcTest.prop([generationNoticeHistory], generationNoticeHistoryCampaign)( `checks generated cache-generation notice histories (random or replayed)`, - assertGenerationNoticeHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(generationNoticeHistoryCampaign)) + return assertGenerationNoticeHistory(...args) + }, ) }) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts index f94331d878..0ac944ac2c 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts @@ -10,6 +10,7 @@ import { } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' +import { oracleRoute } from '../../db/tests/oracle-config' import { DISK_ROW, REMOTE_FAILURE, @@ -342,6 +343,7 @@ describe(`persisted readiness oracle`, () => { [`${effects}: ${describe_(events)}`, events, effects] as const, ), )(`%s`, async (name, events, effects) => { + oracleRoute(`case`) const source = networkFirst(`network-first-${name}`) if (effects === `throwing`) throwOnReady(source.collection) const live = createLiveQueryCollection((q) => @@ -424,4 +426,8 @@ function describe_(events: ReadonlyArray): string { .join(` > `) } -declareOracle({ model: [expected], grammar: [histories] }) +declareOracle({ + owns: [`src/persisted.ts`], + model: [expected], + grammar: [histories], +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts index 9279d5c4a2..82f212f684 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-reload-metadata.test.ts @@ -1799,7 +1799,7 @@ describe(`persistedCollectionOptions`, () => { }, ) - // Hostile output controls calibrate the same comparison used above and by + // Mutant output controls calibrate the same comparison used above and by // the captured-read source history. A missing key, an obsolete defined // field, and an obsolete own-undefined field must all be rejected. These // controls do not substitute for the production RED witnesses above. diff --git a/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts index f14ed02fba..a6a9ac0d8d 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-reload-replacement-oracle.test.ts @@ -3,6 +3,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' import { persistedCollectionOptions } from '../src/index.js' +import { oracleRoute } from '../../db/tests/oracle-config' import type { PersistenceAdapter } from '../src/index.js' import type { SyncConfig } from '@tanstack/db' @@ -222,6 +223,7 @@ describe(`persisted reload replacement oracle`, () => { [`${history.phase}: ${steps(history).join(` > `)}`, history] as const, ), )(`%s`, async (name, history) => { + oracleRoute(`case`) const store = storingAdapter() const runs: Array = [] const collection = createCollection( @@ -305,4 +307,8 @@ describe(`persisted reload replacement oracle`, () => { }) }) -declareOracle({ model: [expected], grammar: [histories] }) +declareOracle({ + owns: [`src/persisted.ts`, `src/sqlite-core-adapter.ts`], + model: [expected], + grammar: [histories], +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts index 64f0c8d3aa..197091d043 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-reset-startup.test.ts @@ -2184,7 +2184,7 @@ describe(`hydration routing probe calibration`, () => { }) // The adapter contract requires call-local forwarding through wrappers. This - // positive/hostile pair reaches the real coordinator but lets the recorder + // positive/mutant pair reaches the real coordinator but lets the recorder // finish bad calls, so losing the loan is a routing assertion, not a timeout. it.each([true, false])( `detects a wrapper that forwards its loan=%s`, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts index 9a4e7a6435..6da1a1cab8 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-source-receipts.test.ts @@ -1210,7 +1210,7 @@ describe(`persistedCollectionOptions`, () => { peerStatus: `ready`, }) - // This hostile control bypasses the sync-run-owned callback. It proves + // This mutant control bypasses the sync-run-owned callback. It proves // the status observation above would reject an unfenced late ready. expect(() => collection._lifecycle.markReady()).toThrowError( expect.objectContaining({ name: `CollectionStateError` }), diff --git a/packages/db-sqlite-persistence-core/tests/persisted-support.ts b/packages/db-sqlite-persistence-core/tests/persisted-support.ts index 72a4afd7db..7e92a2d877 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-support.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-support.ts @@ -1345,11 +1345,11 @@ export const assertGenerationNoticeHistory = async ({ * loss: the load rejects as after a failed ensure, and the demand stays * active and retries instead of releasing the owner's acquisition. * - * Known gap (#2139): a rotated follower never ends scoped recovery, so the + * Known limit (#2139): a rotated follower never ends scoped recovery, so the * owner's row never becomes visible in the rotated placement. Its cells are - * pinned as failing, and the generated histories omit that placement. A + * fixed histories that fail, and the generated histories omit that placement. A * rotation that starts while a load waits for the owner's commits is also - * outside this lane and tracked there. + * outside this history grammar and tracked there. */ export type FollowerRemoteDemandHistory = { placement: `direct` | `queued` | `rotated` | `expired` diff --git a/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts index a18a60c62c..e82f3f9ad6 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-sync-problem-oracle.test.ts @@ -3,6 +3,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' import { persistedCollectionOptions } from '../src/index.js' +import { oracleRoute } from '../../db/tests/oracle-config' import type { PersistedCollectionCoordinator, PersistenceAdapter, @@ -489,6 +490,7 @@ describe(`persisted sync problem oracle`, () => { ] as const, ), )(`%s`, async (name, history) => { + oracleRoute(`case`) const source = createSource(history, `sync-problem ${name}`) const { collection, runs } = source const capture = captureUncaught() @@ -594,4 +596,8 @@ describe(`persisted sync problem oracle`, () => { }) }) -declareOracle({ model: [expected], grammar: [histories] }) +declareOracle({ + owns: [`src/persisted.ts`], + model: [expected], + grammar: [histories], +}) diff --git a/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts b/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts index 776779caf8..3cdc325af9 100644 --- a/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts @@ -1682,4 +1682,6 @@ export const fixedReloadHistories: ReadonlyArray<[string, ReloadHistory]> = [ ], ] +// Owns nothing: this module holds the model and check only. The production +// path is driven by the browser and Electron reload-class tests, which declare their own owned entries, which declares the owned entry. declareOracle({ check: [reloadViolations], model: [expectedReload] }) diff --git a/packages/db-sqlite-persistence-core/tests/reserved-commit-sqlite-adapter-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/reserved-commit-sqlite-adapter-oracle.test.ts index 24ab31cfc2..88ac9e677f 100644 --- a/packages/db-sqlite-persistence-core/tests/reserved-commit-sqlite-adapter-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/reserved-commit-sqlite-adapter-oracle.test.ts @@ -50,6 +50,7 @@ import { eagerStorageTarget, persistedCollectionOptions, } from '../src' +import { oracleRoute } from '../../db/tests/oracle-config' import type { ProtocolEnvelope, SQLiteDriver, TxCommitted } from '../src' import type { SyncConfig } from '@tanstack/db' import type { SQLInputValue } from 'node:sqlite' @@ -483,12 +484,16 @@ describe('reserved commits across real-adapter hydrations', () => { }).toEqual(expected.observation) } declareOracle({ + owns: [`src/sqlite-core-adapter.ts`, `src/persisted.ts`], check: [runAdapterHistory], model: [expectedAdapterOutcome], grammar: [adapterHistories], }) it.each(adapterHistories)( 'keeps a reserved commit lawful across a real-adapter hydration ($route, $timing, $nesting, $ending)', - runAdapterHistory, + (...args: Parameters) => { + oracleRoute(`case`) + return runAdapterHistory(...args) + }, ) }) diff --git a/packages/db-sqlite-persistence-core/tests/shared-logical-scheduling-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/shared-logical-scheduling-oracle.test.ts index 4d7162ded3..023d97199d 100644 --- a/packages/db-sqlite-persistence-core/tests/shared-logical-scheduling-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/shared-logical-scheduling-oracle.test.ts @@ -48,10 +48,20 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { createSQLiteCorePersistenceAdapter } from '../src' +import { + oracleFixedSeed, + oracleRoute, + oracleRouteOf, +} from '../../db/tests/oracle-config' import { PromiseBrandedFirstQueryGatedDriver } from './shared-logical-scheduling-support' describe(`shared logical scheduling`, () => { it(`keeps generated promise-discovered hydrate units non-preemptible`, async () => { + const parameters = oracleFixedSeed(1868, { + numRuns: 12, + endOnFailure: true, + }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -94,7 +104,7 @@ describe(`shared logical scheduling`, () => { ) }, ), - { seed: 1868, numRuns: 12, endOnFailure: true }, + parameters, ) }) }) diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-boolean-arity-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-boolean-arity-oracle.test.ts index 713af85105..4d71b49d41 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-boolean-arity-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-boolean-arity-oracle.test.ts @@ -3,9 +3,12 @@ import { fc } from '@fast-check/vitest' import { expect, it } from 'vitest' import { IR } from '@tanstack/db' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, recordOracleReplay, } from '../../db/tests/oracle-config' @@ -458,6 +461,7 @@ oracleReplayTest(it, booleanArityProperty)( original ??= sameLaw throw new Error(JSON.stringify(brief(sameLaw))) }) + oracleRoute(oracleRouteOf(parameters)) const result = await fc.check(property, parameters) recordOracleReplay(booleanArityProperty, result) const reducedFailures = result.counterexample @@ -518,7 +522,9 @@ oracleReplayTest(it, booleanArityProperty)( const failures: Array = [] for (const model of matrix()) failures.push(...(await check(model))) - const fixed = await campaign({ numRuns: oracleRuns(150), seed: 1994 }) + const fixed = await campaign( + oracleFixedSeed(1994, { numRuns: oracleRuns(150) }), + ) const random = await campaign( oraclePropertyOptions(150, booleanArityProperty), ) 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 54baa27672..384c525daf 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 @@ -20,8 +20,11 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { SQLiteCorePersistenceAdapter, createPersistedTableName } from '../src' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, recordOracleReplay, } from '../../db/tests/oracle-config.js' @@ -50,6 +53,7 @@ import type { } from './sqlite-core-adapter-support' declareOracle({ + owns: [`src/sqlite-core-adapter.ts`], check: [expectResetResumeLaw, violation], model: [expectedResetResumeMetadata, expectedBindingRows], }) @@ -527,7 +531,7 @@ export function runSQLiteResetResumeOracleTests( * * Observed result and known omissions: exact settled rows, exact metadata * keys, and whether the durable Electric state is absent/reset/resume. The - * external-loss lane proves the generic adapter leaves metadata reachable; + * external-loss grammar proves the generic adapter leaves metadata reachable; * only the persisted+Electric suite judges whether that cursor is safe to * consume. The injected loss is not a claim that arbitrary SQL is a * supported public API. Partial resumed updates and SDK framing retain @@ -542,6 +546,7 @@ export function runSQLiteResetResumeOracleTests( oracleReplayTest(it, `sqlite-resume.baseline-lineage`)( `resets collection metadata with its persisted baseline across generated adapter reopen histories`, async () => { + oracleRoute(`fixed seed`) const historyArbitrary = fc .constantFrom( `compatible-reopen`, @@ -583,7 +588,7 @@ export function runSQLiteResetResumeOracleTests( }), ) - // A seed-only shared run reseeds this campaign. Ordinary runs keep the + // A shared run with only a seed is a reseeded campaign of this property. Ordinary runs keep the // fixed seed 1659, and a path replay runs once from its recorded path. const parameters = oraclePropertyOptions( 24, @@ -666,6 +671,7 @@ export function runSQLiteResetResumeOracleTests( ) it(`leaves externally inconsistent metadata reachable for consumer validation`, async () => { + oracleRoute(`fixed history`) const observation = await observeResetResumeHistory( { fromSchemaVersion: 1, @@ -700,6 +706,7 @@ export function runSQLiteResetResumeOracleTests( }, 30_000) it(`requires complete metadata reset while preserving non-reset metadata`, () => { + oracleRoute(`fixed history`) const compatibleEmpty: ResetResumeHistory = { fromSchemaVersion: 1, rows: [], @@ -808,7 +815,7 @@ export function runSQLiteResetResumeOracleTests( * * Refinement: require the exact rows, reached predicate SELECTs, no * SQLite error, and no statement above its declared binding cap. The original 900-item - * OR-chunk lowering is the hostile design: it reaches the intended SELECT and + * OR-chunk lowering is the mutant design: it reaches the intended SELECT and * fails the public result/binding comparison. The legacy CLI test interpolates * literals, so it cannot exercise this boundary. * @@ -900,6 +907,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { bindingFixedIt( `returns exact rows at single-list, statement-total, and typed boundaries`, async () => { + oracleRoute(`case`) const failures: Array = [] for (const spec of fixed) { const observation = await observe(spec) @@ -916,6 +924,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { bindingFixedIt( `honors total capacity through transactionWithDriver`, async () => { + oracleRoute(`fixed history`) const failures: Array = [] for (const spec of [ single(`string`, 999), @@ -937,6 +946,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { bindingFixedIt( `honors the root connection limit when a distinct transaction driver omits it`, async () => { + oracleRoute(`fixed history`) for (const count of [100, 101]) { const spec: Spec = { label: `${count}-scalar-conjunction`, @@ -962,6 +972,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { bindingFixedIt( `never submits an over-capacity statement with many scalar predicates`, async () => { + oracleRoute(`fixed history`) const scalarStress: Spec = { label: `1000-scalar-conjunction`, valueKind: `string`, @@ -998,6 +1009,7 @@ export function runSQLiteBindingCapacityOracleSuite(): void { ) } }) + oracleRoute(oracleRouteOf(parameters)) const details = await fc.check(property, parameters) recordOracleReplay(BINDING_PROPERTY, details) const reduced = details.counterexample?.[0] @@ -1014,20 +1026,22 @@ export function runSQLiteBindingCapacityOracleSuite(): void { ) expect( details.failed, - `${label}: seed=${details.seed} path=${details.counterexamplePath ?? ``} first=${firstFailure?.kind ?? ``}@${firstFailure?.checkpoint ?? ``} reduced=${reducedProblem?.kind ?? ``}@${reducedProblem?.checkpoint ?? ``} sameViolation=${sameViolation}; ${details.error ?? ``}`, + `${label}: seed=${details.seed} path=${details.counterexamplePath ?? ``} first=${firstFailure?.kind ?? ``}@${firstFailure?.checkpoint ?? ``} reduced=${reducedProblem?.kind ?? ``}@${reducedProblem?.checkpoint ?? ``} sameViolation=${sameViolation}; ${String(details.errorInstance ?? ``)}`, ).toBe(false) } bindingFixedIt( `preserves exact rows across fixed-seed binding histories`, async () => { - await campaign(`fixed seed`, { - numRuns: oracleRuns(BINDING_RUNS), - seed: BINDING_FIXED_SEED, - }) + await campaign( + `fixed seed`, + oracleFixedSeed(BINDING_FIXED_SEED, { + numRuns: oracleRuns(BINDING_RUNS), + }), + ) }, ) - // Runs in ordinary and seed-only runs, and as the replay of its property. + // Runs in ordinary and reseeded campaigns, and as the replay of its property. oracleReplayTest(it, BINDING_PROPERTY)( `preserves exact rows across random-seed binding histories`, async () => { diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts index 61eb42f87c..a0bcef4a6d 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts @@ -3,9 +3,12 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { createCollection } from '@tanstack/db' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -329,7 +332,10 @@ describe(`SQLite resume snapshots`, () => { { oldClaim: `renew`, middleClaim: `renew`, rowCount: 2, checkAtMs: 1_230 }, ] as const)( `collects only unclaimed retired storage after $oldClaim and $middleClaim`, - assertRetiredStorageHistory, + (...args: Parameters) => { + oracleRoute(`case`) + return assertRetiredStorageHistory(...args) + }, ) const retiredStorageHistory = fc.record({ oldClaim: fc.constantFrom( @@ -352,16 +358,26 @@ describe(`SQLite resume snapshots`, () => { 1_230, ), }) - fcTest.prop([retiredStorageHistory], { - seed: 2069, + const retiredStorageHistoryFixedSeed = oracleFixedSeed(2069, { numRuns: oracleRuns(24), - })(`checks retired storage reachability (fixed)`, assertRetiredStorageHistory) - fcTest.prop( - [retiredStorageHistory], - oraclePropertyOptions(24, `sqlite-resume.retired-storage`), - )( + }) + fcTest.prop([retiredStorageHistory], retiredStorageHistoryFixedSeed)( + `checks retired storage reachability (fixed)`, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(retiredStorageHistoryFixedSeed)) + return assertRetiredStorageHistory(...args) + }, + ) + const retiredStorageHistoryCampaign = oraclePropertyOptions( + 24, + `sqlite-resume.retired-storage`, + ) + fcTest.prop([retiredStorageHistory], retiredStorageHistoryCampaign)( `checks retired storage reachability (random or replayed)`, - assertRetiredStorageHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(retiredStorageHistoryCampaign)) + return assertRetiredStorageHistory(...args) + }, ) // A resume snapshot may pass its preliminary claim check and then wait to @@ -593,13 +609,15 @@ describe(`SQLite resume snapshots`, () => { { clockCut: `after expiry`, elapsedMs: 1, includeRows: true }, ])( `checks a cache claim $clockCut inside a resume transaction with includeRows=$includeRows`, - ({ elapsedMs, includeRows }) => - assertManagedClaimReadHistory({ + ({ elapsedMs, includeRows }) => { + oracleRoute(`case`) + return assertManagedClaimReadHistory({ elapsedMs, includeRows, peerDelayMs: 50, rowCount: 1, - }), + }) + }, ) const managedClaimReadHistory = fc.record({ @@ -608,19 +626,26 @@ describe(`SQLite resume snapshots`, () => { peerDelayMs: fc.integer({ min: 10, max: 80 }), rowCount: fc.integer({ min: 0, max: 3 }), }) - fcTest.prop([managedClaimReadHistory], { - seed: 2056, + const managedClaimReadHistoryFixedSeed = oracleFixedSeed(2056, { numRuns: oracleRuns(30), - })( + }) + fcTest.prop([managedClaimReadHistory], managedClaimReadHistoryFixedSeed)( `checks generated managed-claim read histories (fixed)`, - assertManagedClaimReadHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(managedClaimReadHistoryFixedSeed)) + return assertManagedClaimReadHistory(...args) + }, + ) + const managedClaimReadHistoryCampaign = oraclePropertyOptions( + 30, + `sqlite-resume.managed-claim-read`, ) - fcTest.prop( - [managedClaimReadHistory], - oraclePropertyOptions(30, `sqlite-resume.managed-claim-read`), - )( + fcTest.prop([managedClaimReadHistory], managedClaimReadHistoryCampaign)( `checks generated managed-claim read histories (random or replayed)`, - assertManagedClaimReadHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(managedClaimReadHistoryCampaign)) + return assertManagedClaimReadHistory(...args) + }, ) const startupHistoryArbitrary = fc.record({ @@ -793,10 +818,11 @@ describe(`SQLite resume snapshots`, () => { } it(`fixed startup corpus reaches every baseline size and transition`, () => { - const histories = fc.sample(startupHistoryArbitrary, { - seed: 1659, - numRuns: 12, - }) + oracleRoute(`fixed history`) + const histories = fc.sample( + startupHistoryArbitrary, + oracleFixedSeed(1659, { numRuns: 12 }), + ) expect(new Set(histories.map(({ baselineSize }) => baselineSize))).toEqual( new Set([1, 2, 3]), ) @@ -842,6 +868,7 @@ describe(`SQLite resume snapshots`, () => { `duplicate source position`, `legacy source progress column added`, ]) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( progressHistory, @@ -960,14 +987,19 @@ describe(`SQLite resume snapshots`, () => { } it.skipIf(oracleReplayRequested())( `preserves source progress evidence (fixed seed)`, - () => runProgressCampaign({ numRuns: oracleRuns(80), seed: 2281 }), + () => { + return runProgressCampaign( + oracleFixedSeed(2281, { numRuns: oracleRuns(80) }), + ) + }, ) oracleReplayTest(it, `sqlite-resume.source-progress`)( `preserves source progress evidence (random or replayed)`, - () => - runProgressCampaign( + () => { + return runProgressCampaign( oraclePropertyOptions(80, `sqlite-resume.source-progress`), - ), + ) + }, ) // The retired-storage and managed-claim checks compare production with independent @@ -975,6 +1007,7 @@ describe(`SQLite resume snapshots`, () => { // cases share those checks. The startup grammar is the generator whose coverage // the fixed startup corpus calibrates. declareOracle({ + owns: [`src/sqlite-core-adapter.ts`], check: [ assertRetiredStorageHistory, assertManagedClaimReadHistory, @@ -984,20 +1017,27 @@ describe(`SQLite resume snapshots`, () => { grammar: [startupHistoryArbitrary, progressHistory], }) - fcTest.prop([startupHistoryArbitrary], { - seed: 1659, + const startupHistoryFixedSeed = oracleFixedSeed(1659, { numRuns: oracleRuns(12), - })( + }) + fcTest.prop([startupHistoryArbitrary], startupHistoryFixedSeed)( `preserves local-only startup histories across managed generation advancement (fixed)`, - assertStartupHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(startupHistoryFixedSeed)) + return assertStartupHistory(...args) + }, ) - fcTest.prop( - [startupHistoryArbitrary], - oraclePropertyOptions(12, `sqlite-resume.startup-generation`), - )( + const startupHistoryCampaign = oraclePropertyOptions( + 12, + `sqlite-resume.startup-generation`, + ) + fcTest.prop([startupHistoryArbitrary], startupHistoryCampaign)( `preserves local-only startup histories across managed generation advancement (random or replayed)`, - assertStartupHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(startupHistoryCampaign)) + return assertStartupHistory(...args) + }, ) }) diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts index 098a8edada..9ec40795c3 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-resume-snapshot-support.ts @@ -248,9 +248,9 @@ export async function observeCachedSchemaState( * compares the entire projected schema state; the held boundary and reset epoch * are reach witnesses, while compatible reopen and recertifying truncate cases * prevent an oracle that merely rejects every resume. - * A separate generated startup lane holds the runtime between its metadata and + * A separate generated startup grammar holds the runtime between its metadata and * hydration snapshots, then crosses no write, a managed public insert, and a - * hostile raw deletion over one-to-three baseline rows. Its independent model + * mutant raw deletion over one-to-three baseline rows. Its independent model * preserves every managed row but fails closed for unsupported raw loss. * The focused work law first executes one controlled expected-key table read to * prove its SQL observer can detect the forbidden membership work, then resets diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-oracle.test.ts index f26c8ee37f..68f14249d0 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-oracle.test.ts @@ -46,6 +46,7 @@ import { IR } from '@tanstack/db' import { Temporal } from 'temporal-polyfill' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config' import { temporalFamilies, temporalQueryCases, @@ -151,12 +152,18 @@ const cases = kinds.flatMap((kind) => ), ) -declareOracle({ grammar: [cases], model: [expectedValue, expectedRows] }) +declareOracle({ + owns: [`src/sqlite-value.ts`, `src/sqlite-core-adapter.ts`], + grammar: [cases], + model: [expectedValue, expectedRows], +}) describe(`SQLite typed-value preservation after reopen`, () => { + oracleRoute(`fixed history`) it.each(cases)( `$kind / $history / indexed=$indexed`, async ({ kind, history, indexed }) => { + oracleRoute(`case`) const harness = scope.create() const { adapter } = harness.open() await adapter.applyCommittedTx( @@ -327,7 +334,9 @@ describe(`SQLite typed-value preservation after reopen`, () => { }) describe(`typed-value oracle calibration`, () => { + oracleRoute(`calibration`) it.each(kinds)(`distinguishes type and value for $0`, (kind) => { + oracleRoute(`calibration`) check(inputValue(kind, 0), expectedValue(kind, 0), `valid value`) expect(() => check({}, expectedValue(kind, 0), `empty object`)).toThrow() expect(() => @@ -341,6 +350,7 @@ describe(`typed-value oracle calibration`, () => { }) it(`rejects missing, duplicate, and reversed query results`, () => { + oracleRoute(`calibration`) const expected = [`z-early`, `a-late`] check(expected, expected, `valid keys`) for (const actual of [ @@ -365,6 +375,7 @@ describe(`typed-value oracle calibration`, () => { // and text come from the independent companion model; SQL never supplies truth. describe(`native Temporal query refinement`, () => { it.each(temporalFamilies)(`$kind / $name`, async (family) => { + oracleRoute(`case`) const harness = scope.create() const { adapter } = harness.open() await adapter.applyCommittedTx( diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts index 1b4db16406..4207059f26 100644 --- a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value.test.ts @@ -3,7 +3,7 @@ * Scenario checks for native Temporal values in the SQLite core adapter: IN * membership against lookalike string keys, and the native literal encoding * budget. `sqlite-temporal-value-oracle.test.ts` owns the typed-value law and its - * grammar; these tests pin single cases with hand-written expectations. + * grammar; these tests check single cases with hand-written expectations. */ import { IR } from '@tanstack/db' import { Temporal } from 'temporal-polyfill' diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts index f172d5432d..e2dffa65c5 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-calibration.test.ts @@ -3,8 +3,8 @@ import { fc } from '@fast-check/vitest' import { expect, it } from 'vitest' import { missingRowMetadataHistoriesArbitrary, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, queuedMetadataRepairHistoriesArbitrary, runSessionHistory, seedReinsertReloadReplay, @@ -52,7 +52,7 @@ it(`rejects a recovery schedule whose boundary the driver cannot hold`, async () bare: false, value: 0, }, - () => createSQLiteDriver(persistedPeerOnDemandLane), + () => createSQLiteDriver(persistedPeerOnDemandGrammar), ), ).rejects.toThrow(`commit completed without reaching after-application`) }) @@ -88,13 +88,15 @@ it(`rejects a wrong durable write even when a later queued write repairs it`, as numRuns: 1, })[0]! for (const { history, bare, held } of cases) { - const baseline = await runSessionHistory(persistedPeerLane, history, () => - createPersistedDriver(true, true, false, true), + const baseline = await runSessionHistory( + persistedPeerGrammar, + history, + () => createPersistedDriver(true, true, false, true), ) expect(baseline.mismatch).toBeUndefined() let injected = false - const hostile = await runSessionHistory( - persistedPeerLane, + const mutant = await runSessionHistory( + persistedPeerGrammar, history, async () => { const driver = await createPersistedDriver(true, true, false, true) @@ -121,8 +123,8 @@ it(`rejects a wrong durable write even when a later queued write repairs it`, as }, ) expect(injected, `${bare}/${held}`).toBe(true) - expect(hostile.mismatch?.law).toContain(`keeping metadata does not write`) - expect(hostile.mismatch?.action).not.toBe(`settle`) + expect(mutant.mismatch?.law).toContain(`keeping metadata does not write`) + expect(mutant.mismatch?.action).not.toBe(`settle`) } }, 30_000) @@ -133,21 +135,26 @@ const hosts: Array<{ name: string; create: () => Promise }> = [ }, { name: `SQLite`, - create: () => createSQLiteDriver(persistedPeerOnDemandLane), + create: () => createSQLiteDriver(persistedPeerOnDemandGrammar), }, ] it.each(hosts)( `rejects wrong source metadata that appears only after reload ($name)`, async ({ create, name }) => { - const lane = - name === `SQLite` ? persistedPeerOnDemandLane : persistedPeerLane + const productionPath = + name === `SQLite` ? persistedPeerOnDemandGrammar : persistedPeerGrammar expect( - (await runSessionHistory(lane, seedReinsertReloadReplay, create)) - .mismatch, + ( + await runSessionHistory( + productionPath, + seedReinsertReloadReplay, + create, + ) + ).mismatch, ).toBeUndefined() let reached = false const result = await runSessionHistory( - lane, + productionPath, seedReinsertReloadReplay, async () => { const driver = await create() @@ -191,7 +198,7 @@ it.each(recoveryPhases)( runRecoverySchedule( { phase, interruption: `seed`, bare: false, value: 7 }, async () => { - const driver = await createSQLiteDriver(persistedPeerOnDemandLane) + const driver = await createSQLiteDriver(persistedPeerOnDemandGrammar) const hold = driver.holdRecovery let holding = false driver.holdRecovery = (selected) => { @@ -235,7 +242,7 @@ it.each(recoveryPhases)( runRecoverySchedule( { phase, interruption: `cleanup`, bare: true, value: 7 }, async () => { - const driver = await createSQLiteDriver(persistedPeerOnDemandLane) + const driver = await createSQLiteDriver(persistedPeerOnDemandGrammar) const stop = driver.stopSource const observe = driver.observe let ended = false diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-campaign-result.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-campaign-result.test.ts index d08f5cd40c..7ae3117988 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-campaign-result.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-campaign-result.test.ts @@ -4,7 +4,11 @@ import { keptMetadataDurabilityHistoriesArbitrary } from '../../db/tests/sync-me import { requireExecutedHistory } from './sync-metadata-campaign-result' it(`rejects the obsolete shrink path that fast-check accepts without executing a history`, async () => { - for (const path of [`0`, `0:0:0`]) { + // `0:999` asks for a shrink step that does not exist, which fast-check + // accepts and answers with no history under any version; a path that + // merely runs deeper than one version's shrink tree, such as `0:0:0` under + // fast-check 3, can be a real history under another. + for (const path of [`0`, `0:999`]) { let calls = 0 const result = await fc.check( fc.asyncProperty(keptMetadataDurabilityHistoriesArbitrary, () => { diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts index 92de039069..67dbe616b8 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-missing-row.test.ts @@ -5,9 +5,9 @@ import { describeMismatch, missingRowMetadataCases, missingRowMetadataHistoriesArbitrary, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, runSessionHistory, } from '../../db/tests/sync-metadata-session-oracle' import { @@ -29,10 +29,10 @@ import { createSQLiteDriver } from './sync-metadata-session-sqlite-driver' * named peer notice. Core holds vary application without delaying durability. * This is one sync run; restart and intermediate SQL statements are outside it. */ -const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, +const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] const requested = readOracleRunConfig().replayProperty for (const [host, property] of [ @@ -46,11 +46,14 @@ for (const [host, property] of [ ) continue it(`keeps missing-row metadata local unless explicitly written (${host}, ${campaign})`, async () => { - const state = (lane: string, name: string) => `${lane}: ${name}` + const state = (productionPathName: string, name: string) => + `${productionPathName}: ${name}` const reach = createOracleReach( property, - lanes.flatMap((lane) => - missingRowMetadataCases.map(({ name }) => state(lane.name, name)), + productionPaths.flatMap((productionPath) => + missingRowMetadataCases.map(({ name }) => + state(productionPath.name, name), + ), ), ) const result = await fc.check( @@ -58,33 +61,33 @@ for (const [host, property] of [ missingRowMetadataHistoriesArbitrary, async (batch) => { const failures: Array = [] - for (const lane of lanes) { + for (const productionPath of productionPaths) { for (const { name, history } of batch) { reach.beginHistory() const historyResult = await runSessionHistory( - lane, + productionPath, history, () => host === `sqlite` - ? createSQLiteDriver(lane) + ? createSQLiteDriver(productionPath) : createPersistedDriver( true, true, false, true, - lane.fullRows, - lane.onDemand, + productionPath.fullRows, + productionPath.onDemand, ), ) if (historyResult.mismatch) { failures.push( - `${state(lane.name, name)}\n${describeMismatch(history, historyResult.mismatch)}`, + `${state(productionPath.name, name)}\n${describeMismatch(history, historyResult.mismatch)}`, ) } else if (historyResult.steps !== history.length) throw new Error( `constructive history skipped an authored action`, ) - else reach.reach(state(lane.name, name)) + else reach.reach(state(productionPath.name, name)) } } if (failures.length) throw new Error(failures.join(`\n\n`)) @@ -101,7 +104,7 @@ for (const [host, property] of [ ) if (result.failed) throw new Error( - `${result.error}\nseed=${result.seed} path=${result.counterexamplePath}`, + `${String(result.errorInstance)}\nseed=${result.seed} path=${result.counterexamplePath}`, ) reach.assertReached() }, 240_000) diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts index 5640bd461b..68549dd04e 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-recovery.test.ts @@ -7,7 +7,7 @@ import { recoverySchedulesArbitrary, runRecoverySchedule, } from '../../db/tests/sync-metadata-recovery-oracle' -import { persistedPeerOnDemandLane } from '../../db/tests/sync-metadata-session-oracle' +import { persistedPeerOnDemandGrammar } from '../../db/tests/sync-metadata-session-oracle' import { oraclePropertyOptions, oracleRuns, @@ -38,7 +38,7 @@ for (const campaign of [`fixed`, `random`] as const) { for (const schedule of batch) { reach.beginHistory() const observation = await runRecoverySchedule(schedule, () => - createSQLiteDriver(persistedPeerOnDemandLane), + createSQLiteDriver(persistedPeerOnDemandGrammar), ) if ( !observation.events.includes(schedule.phase) || @@ -60,7 +60,7 @@ for (const campaign of [`fixed`, `random`] as const) { requireExecutedHistory(property, result) if (result.failed) throw new Error( - `${result.error}\nseed=${result.seed} path=${result.counterexamplePath}`, + `${String(result.errorInstance)}\nseed=${result.seed} path=${result.counterexamplePath}`, ) reach.assertReached() console.info( diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts index 7a87a0d09a..418972808d 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-campaign.ts @@ -3,13 +3,16 @@ import { completedSeedReinsertReloadRegime, describeMismatch, keptMetadataDurabilityHistoriesArbitrary, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, seedReinsertReloadHistoryArbitrary, } from '../../db/tests/sync-metadata-session-oracle' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleRouteOf, oracleRuns, recordOracleReplay, } from '../../db/tests/oracle-config' @@ -17,7 +20,7 @@ import { createOracleReach } from '../../db/tests/oracle-reach' import { requireExecutedHistory } from './sync-metadata-campaign-result' import type { SessionAction, - SessionLane, + SessionGrammar, runSessionHistory, } from '../../db/tests/sync-metadata-session-oracle' @@ -36,32 +39,37 @@ export async function runSeedReinsertReloadCampaign({ campaign: `fixed` | `random` runHistory: ( history: ReadonlyArray, - lane: SessionLane, + productionPath: SessionGrammar, ) => ReturnType }): Promise { - const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, + const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] - const states = lanes.flatMap((lane) => [ - `${lane.name}: inner commit`, - `${lane.name}: row`, - `${lane.name}: reload`, + const states = productionPaths.flatMap((productionPath) => [ + `${productionPath.name}: inner commit`, + `${productionPath.name}: row`, + `${productionPath.name}: reload`, ]) - const completedStates = lanes.flatMap((lane) => [ - `${lane.name}: completed kept bare seed reload`, - `${lane.name}: completed kept non-bare seed reload`, + const completedStates = productionPaths.flatMap((productionPath) => [ + `${productionPath.name}: completed kept bare seed reload`, + `${productionPath.name}: completed kept non-bare seed reload`, ]) const reach = createOracleReach(property, states) const completedReach = createOracleReach(property, completedStates) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(527962096, { numRuns: oracleRuns(40) }) + : oraclePropertyOptions(40, property) + oracleRoute(oracleRouteOf(parameters)) const result = await fc.check( fc.asyncProperty(seedReinsertReloadHistoryArbitrary, async (history) => { const failures: Array = [] - for (const lane of lanes) { + for (const productionPath of productionPaths) { reach.beginHistory() completedReach.beginHistory() - const result = await runHistory(history, lane) + const result = await runHistory(history, productionPath) if (!result.mismatch && result.steps !== history.length) throw new Error(`constructive history skipped an authored action`) const last = history.at(-1)! @@ -69,22 +77,20 @@ export async function runSeedReinsertReloadCampaign({ (action) => action.type === `commit`, ) if (result.steps > innerCommit) - reach.reach(`${lane.name}: inner commit`) + reach.reach(`${productionPath.name}: inner commit`) if (last.type === `peer` && result.steps === history.length) - reach.reach(`${lane.name}: ${last.notice}`) + reach.reach(`${productionPath.name}: ${last.notice}`) const completed = completedSeedReinsertReloadRegime(history, result) if (completed !== undefined) - completedReach.reach(`${lane.name}: ${completed}`) + completedReach.reach(`${productionPath.name}: ${completed}`) if (result.mismatch) failures.push( - `${lane.name}\n${describeMismatch(history, result.mismatch)}`, + `${productionPath.name}\n${describeMismatch(history, result.mismatch)}`, ) } if (failures.length) throw new Error(failures.join(`\n\n`)) }), - campaign === `fixed` - ? { numRuns: oracleRuns(40), seed: 527962096 } - : oraclePropertyOptions(40, property), + parameters, ) recordOracleReplay(property, result) requireExecutedHistory(property, result) @@ -93,7 +99,7 @@ export async function runSeedReinsertReloadCampaign({ ) if (result.failed) throw new Error( - `${result.error}\nseed=${result.seed} path=${result.counterexamplePath}`, + `${String(result.errorInstance)}\nseed=${result.seed} path=${result.counterexamplePath}`, ) reach.assertReached() // The bare-key-1 conjunction has probability 1/24: random40 would miss it @@ -119,68 +125,72 @@ export async function runKeptMetadataDurabilityCampaign({ campaign: `fixed` | `random` runHistory: ( history: ReadonlyArray, - lane: SessionLane, + productionPath: SessionGrammar, ) => ReturnType extraStates?: ReadonlyArray extraReached?: ( - lane: SessionLane, + productionPath: SessionGrammar, result: Awaited>, ) => ReadonlyArray }): Promise { - const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, + const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] const name = ( - lane: SessionLane, + productionPath: SessionGrammar, write: string, bare: boolean, metadata: boolean, notice: string, ) => - `${lane.name}: ${write}: ${bare ? `bare` : `metadata`} seed: ${metadata ? `explicit` : `implicit`}: ${notice}` - const states = lanes.flatMap((lane) => + `${productionPath.name}: ${write}: ${bare ? `bare` : `metadata`} seed: ${metadata ? `explicit` : `implicit`}: ${notice}` + const states = productionPaths.flatMap((productionPath) => [`update`, `reinsert`, `inner-reinsert`].flatMap((write) => [false, true].flatMap((bare) => [false, true].flatMap((metadata) => [`none`, `row`, `reload`].map((notice) => - name(lane, write, bare, metadata, notice), + name(productionPath, write, bare, metadata, notice), ), ), ), ), ) const reach = createOracleReach(property, [...states, ...extraStates]) + const campaignParameters = + campaign === `fixed` + ? oracleFixedSeed(2220031, { numRuns: oracleRuns(8) }) + : oraclePropertyOptions(8, property) + oracleRoute(oracleRouteOf(campaignParameters)) const result = await fc.check( fc.asyncProperty( keptMetadataDurabilityHistoriesArbitrary, async (batch) => { const failures: Array = [] - for (const lane of lanes) { + for (const productionPath of productionPaths) { for (const { write, bare, metadata, notice, history } of batch) { reach.beginHistory() - const result = await runHistory(history, lane) + const result = await runHistory(history, productionPath) if (result.mismatch) failures.push( - `${lane.name}\n${describeMismatch(history, result.mismatch)}`, + `${productionPath.name}\n${describeMismatch(history, result.mismatch)}`, ) else { if (result.steps !== history.length) throw new Error( `constructive history skipped an authored action`, ) - reach.reach(name(lane, write, bare, metadata, notice)) - for (const state of extraReached(lane, result)) reach.reach(state) + reach.reach(name(productionPath, write, bare, metadata, notice)) + for (const state of extraReached(productionPath, result)) + reach.reach(state) } } } if (failures.length) throw new Error(failures.join(`\n\n`)) }, ), - campaign === `fixed` - ? { numRuns: oracleRuns(8), seed: 2220031 } - : oraclePropertyOptions(8, property), + campaignParameters, ) recordOracleReplay(property, result) requireExecutedHistory(property, result) @@ -189,7 +199,7 @@ export async function runKeptMetadataDurabilityCampaign({ ) if (result.failed) throw new Error( - `${result.error}\nseed=${result.seed} path=${result.counterexamplePath}`, + `${String(result.errorInstance)}\nseed=${result.seed} path=${result.counterexamplePath}`, ) reach.assertReached() } diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts index fff5c1ec87..ac8a00e2d1 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-driver.ts @@ -37,10 +37,10 @@ import type { PersistedTx, PersistenceAdapter, ProtocolEnvelope } from '../src' * Checkpoints: source reads after every action; `collection.base` only at the * final checkpoint, after every hold ends and the wrapper's continuations * drain. The wrapper hands rows to core after its durable step, so consumers - * see them a turn or more later even with no hold. In the peer lanes the + * see them a turn or more later even with no hold. In the peer production paths the * holding adapter's stored rows are compared at the final checkpoint too. * - * Lanes: `persisted-flat`, `persisted-nested`, `persisted-seeds`, + * Production paths: `persisted-flat`, `persisted-nested`, `persisted-seeds`, * `persisted-cached` (row 1 is in storage when the run starts), * `persisted-conflicts` (inner writes that land ahead of enclosing ones), and * `persisted-nested-seeds`, which crosses seeds with nesting and the cached @@ -52,7 +52,7 @@ import type { PersistedTx, PersistenceAdapter, ProtocolEnvelope } from '../src' * `persisted-peers-on-demand`, which loads on demand with a demand for every * row active from the start. Seeds * use a `DbClient` that owns the persisted Collection. Restart and resume - * reads are outside these lanes. + * reads are outside these production paths. * * A peer is a second writer the driver plays. It writes the stored row and * metadata directly, takes the next stream position after this tab's @@ -215,8 +215,8 @@ const drainTurns = async () => { let run = 0 /** - * Drives one persisted Collection through the shared session contract. Lane - * options select hydration seeds, cached rows, storage holds and peer notices; + * Drives one persisted Collection through the shared session contract. Production + * path options select hydration seeds, cached rows, storage holds and peer notices; * source reads and durable observations remain separate comparison inputs. */ export async function createPersistedDriver( diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts index 251b541146..181b917dd9 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted.test.ts @@ -3,17 +3,17 @@ import { describe, expect, it } from 'vitest' import { describeMismatch, historyRegimes, - persistedCachedLane, - persistedConflictLane, - persistedFlatLane, - persistedNestedLane, - persistedNestedSeedLane, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, - persistedReadLane, - persistedReadSeedLane, - persistedSeedLane, + persistedCachedGrammar, + persistedConflictGrammar, + persistedFlatGrammar, + persistedNestedGrammar, + persistedNestedSeedGrammar, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, + persistedReadGrammar, + persistedReadSeedGrammar, + persistedSeedGrammar, queuedMetadataRepairHistoriesArbitrary, runSessionHistory, seedReinsertReloadReplay, @@ -35,7 +35,7 @@ import { } from './sync-metadata-session-campaign' import type { SessionAction, - SessionLane, + SessionGrammar, } from '../../db/tests/sync-metadata-session-oracle' /** @@ -56,10 +56,10 @@ import type { * Checkpoints: source reads after every action; `collection.base` only at the * final checkpoint, after every hold ends and the wrapper's continuations * drain. The wrapper hands rows to core after its durable step, so consumers - * see them a turn or more later even with no hold. In the peer lanes the + * see them a turn or more later even with no hold. In the peer production paths the * holding adapter's stored rows are compared at the final checkpoint too. * - * Lanes: `persisted-flat`, `persisted-nested`, `persisted-seeds`, + * Production paths: `persisted-flat`, `persisted-nested`, `persisted-seeds`, * `persisted-cached` (row 1 is in storage when the run starts), * `persisted-conflicts` (inner writes that land ahead of enclosing ones), and * `persisted-nested-seeds`, which crosses seeds with nesting and the cached @@ -71,7 +71,7 @@ import type { * `persisted-peers-on-demand`, which loads on demand with a demand for every * row active from the start. Seeds * use a `DbClient` that owns the persisted Collection. Restart and resume - * reads are outside these lanes. + * reads are outside these production paths. * * A peer is a second writer the driver plays. It writes the stored row and * metadata directly, takes the next stream position after this tab's @@ -87,16 +87,16 @@ import type { async function assertPersistedHistory( history: ReadonlyArray, - lane: SessionLane = persistedFlatLane, + productionPath: SessionGrammar = persistedFlatGrammar, ): Promise { - const { mismatch } = await runSessionHistory(lane, history, () => + const { mismatch } = await runSessionHistory(productionPath, history, () => createPersistedDriver( - lane.seeds, - lane.cached === true, - lane.readHold === true, - lane.peers === true, - lane.fullRows === true, - lane.onDemand === true, + productionPath.seeds, + productionPath.cached === true, + productionPath.readHold === true, + productionPath.peers === true, + productionPath.fullRows === true, + productionPath.onDemand === true, ), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) @@ -152,7 +152,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `begin` }, { type: `reinsert`, key: 1, metadata: false }, ], - persistedSeedLane, + persistedSeedGrammar, ) }) @@ -169,7 +169,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `commit` }, { type: `seed`, key: 1, bare: true }, ], - persistedSeedLane, + persistedSeedGrammar, ) }) @@ -186,7 +186,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `commit` }, { type: `seed`, key: 2, bare: true }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -199,7 +199,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: false }, { type: `seed`, key: 1, bare: true }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -217,7 +217,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: true }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -237,7 +237,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: true }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -255,7 +255,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `seed`, key: 1 }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -273,7 +273,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `upsert`, key: 1, metadata: false }, { type: `commit` }, ], - persistedNestedSeedLane, + persistedNestedSeedGrammar, ) }) @@ -282,7 +282,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { it(`reads an enclosing transaction's write inside a nested transaction`, async () => { await assertPersistedHistory( [{ type: `begin` }, { type: `rowSet`, key: 1 }, { type: `begin` }], - persistedNestedLane, + persistedNestedGrammar, ) }) @@ -301,7 +301,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { { type: `reinsert`, key: 1, metadata: false }, { type: `commit` }, ], - persistedConflictLane, + persistedConflictGrammar, ) }) @@ -318,13 +318,13 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { it(`reaches held and open-over-held reads in the fixed campaign`, () => { const counts = new Map() for (const history of fc.sample( - sessionHistoryArbitrary(persistedFlatLane), + sessionHistoryArbitrary(persistedFlatGrammar), { numRuns: oracleRuns(50), seed: fixedSeed, }, )) - for (const regime of historyRegimes(persistedFlatLane, history)) + for (const regime of historyRegimes(persistedFlatGrammar, history)) counts.set(regime, (counts.get(regime) ?? 0) + 1) for (const regime of [ `read with two held transactions`, @@ -336,7 +336,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { }) if (requestedReplayProperty === undefined) { - fcTest.prop([sessionHistoryArbitrary(persistedFlatLane)], { + fcTest.prop([sessionHistoryArbitrary(persistedFlatGrammar)], { numRuns: oracleRuns(50), seed: fixedSeed, })( @@ -345,7 +345,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { 120_000, ) fcTest.prop( - [sessionHistoryArbitrary(persistedFlatLane)], + [sessionHistoryArbitrary(persistedFlatGrammar)], oraclePropertyOptions(50, property), )( `keeps the session law on flat histories (random)`, @@ -354,7 +354,7 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { ) } else if (requestedReplayProperty === property) { fcTest.prop( - [sessionHistoryArbitrary(persistedFlatLane)], + [sessionHistoryArbitrary(persistedFlatGrammar)], oraclePropertyOptions(50, property), )( `replays persisted flat session histories`, @@ -365,33 +365,33 @@ describe(`sync metadata session oracle through the persisted wrapper`, () => { }) /** - * Each lane's fixed campaign and the states it must reach (ORC-015). The - * conflict lane runs more histories: an insert over a different row needs an + * Each production path's fixed campaign and the states it must reach (ORC-015). The + * conflict production path runs more histories: an insert over a different row needs an * outer insert, an inner write to the same row, and the inner commit. */ -const extraLanes: Array<{ - lane: SessionLane +const extraProductionPaths: Array<{ + productionPath: SessionGrammar property: string seed: number runs: number reach: Array }> = [ { - lane: persistedNestedLane, + productionPath: persistedNestedGrammar, property: `sync-metadata-session.persisted-nested`, seed: 2_220_012, runs: 50, reach: [`nested commit`, `nested commit during a hold`, `store hold`], }, { - lane: persistedSeedLane, + productionPath: persistedSeedGrammar, property: `sync-metadata-session.persisted-seeds`, seed: 2_220_013, runs: 50, reach: [`seed`, `store hold`], }, { - lane: persistedCachedLane, + productionPath: persistedCachedGrammar, property: `sync-metadata-session.persisted-cached`, seed: 2_220_015, runs: 80, @@ -402,7 +402,7 @@ const extraLanes: Array<{ ], }, { - lane: persistedConflictLane, + productionPath: persistedConflictGrammar, property: `sync-metadata-session.persisted-conflicts`, seed: 2_220_014, runs: 150, @@ -416,7 +416,7 @@ const extraLanes: Array<{ // Writes while a storage read is in flight follow the same laws: the // wrapper buffers their transactions until the read ends. { - lane: persistedReadLane, + productionPath: persistedReadGrammar, property: `sync-metadata-session.persisted-reads`, seed: 2_220_017, runs: 150, @@ -430,9 +430,9 @@ const extraLanes: Array<{ // A transaction committed during a read and abandoned before the wrapper // hands it to core never wrote its rows, so a seed beneath it still holds // them (decision 21). Abandoning a seeded row's writer is rare here; a - // replay pins it. + // fixed history covers it. { - lane: persistedReadSeedLane, + productionPath: persistedReadSeedGrammar, property: `sync-metadata-session.persisted-reads-seeds`, seed: 2_220_021, runs: 150, @@ -443,7 +443,7 @@ const extraLanes: Array<{ // seeds (decision 11); a full reload keeps a held seed and makes every // changed stored row a source write (decisions 13 and 15). { - lane: persistedPeerLane, + productionPath: persistedPeerGrammar, property: `sync-metadata-session.persisted-peers`, seed: 2_220_018, runs: 150, @@ -458,10 +458,10 @@ const extraLanes: Array<{ `peer write during a hold`, ], }, - // The peer lane under full row updates: an update replaces its row, so + // The peer production path under full row updates: an update replaces its row, so // storage must not keep a seed's fields beneath it (decision 17). { - lane: persistedPeerFullLane, + productionPath: persistedPeerFullGrammar, property: `sync-metadata-session.persisted-peers-full`, seed: 2_220_019, runs: 150, @@ -477,11 +477,11 @@ const extraLanes: Array<{ `accepted update drops a field`, ], }, - // The peer lane on demand: a reset is a source truncate that keeps the + // The peer production path on demand: a reset is a source truncate that keeps the // seeds this tab holds (decisions 19 and 20). Metadata of a missing row is - // rare here; a replay pins a reset over it. + // rare here; a fixed history covers a reset over it. { - lane: persistedPeerOnDemandLane, + productionPath: persistedPeerOnDemandGrammar, property: `sync-metadata-session.persisted-peers-on-demand`, seed: 2_220_020, runs: 150, @@ -500,7 +500,7 @@ const extraLanes: Array<{ // re-insert kept, here also when the re-insert committed ahead of the // enclosing transaction whose row it re-inserted. { - lane: persistedNestedSeedLane, + productionPath: persistedNestedSeedGrammar, property: `sync-metadata-session.persisted-nested-seeds`, seed: 2_220_016, runs: 300, @@ -515,7 +515,7 @@ const extraLanes: Array<{ ] /** - * Shrunk peer-lane counterexamples, replayed through the same model, driver, + * Shrunk counterexamples from the peer production paths, replayed through the same model, driver, * and comparison as the campaigns. Each reached a defect that the fixed and * random campaigns rarely generate: a held seed that a reload must keep, a * row that held transactions wrote, metadata of a missing row, a write over a @@ -772,25 +772,26 @@ const readSeedReplays: Array<[string, Array]> = [ describe(`sync metadata session oracle through the persisted wrapper: peer replays`, () => { it.each(peerReplays)(`replays: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerLane), + assertPersistedHistory(history, persistedPeerGrammar), ) it.each(peerFullReplays)(`replays under full rows: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerFullLane), + assertPersistedHistory(history, persistedPeerFullGrammar), ) - // Pinned from the persisted-peers-full random campaign (seed 654774679): + // Fixed history from the persisted-peers-full random campaign (seed 654774679): // an outer transaction inserts over a bare seed and an inner equal re-insert // commits first. Its implicit metadata keep writes no metadata to storage; // the shared model judges source reads and the later unknown-author reload // separately under D9, D15 and D18. This uses the campaigns' model, driver, // and comparison. it(`replays under full rows: keeps source metadata reads consistent across an inner re-insert and reload`, () => - assertPersistedHistory(seedReinsertReloadReplay, persistedPeerFullLane)) + assertPersistedHistory(seedReinsertReloadReplay, persistedPeerFullGrammar)) it.each(peerOnDemandReplays)(`replays on demand: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerOnDemandLane), + assertPersistedHistory(history, persistedPeerOnDemandGrammar), ) it.each(readSeedReplays)( `replays with reads and seeds: %s`, - (_name, history) => assertPersistedHistory(history, persistedReadSeedLane), + (_name, history) => + assertPersistedHistory(history, persistedReadSeedGrammar), ) }) @@ -808,15 +809,15 @@ for (const campaign of [`fixed`, `random`] as const) { await runSeedReinsertReloadCampaign({ property: seedReloadProperty, campaign, - runHistory: (history, lane) => - runSessionHistory(lane, history, () => + runHistory: (history, productionPath) => + runSessionHistory(productionPath, history, () => createPersistedDriver( true, true, false, true, - lane.fullRows === true, - lane.onDemand === true, + productionPath.fullRows === true, + productionPath.onDemand === true, ), ), }) @@ -825,52 +826,60 @@ for (const campaign of [`fixed`, `random`] as const) { const describeNested = oracleReplayTest( describe, - ...extraLanes.map(({ property }) => property), + ...extraProductionPaths.map(({ property }) => property), ) describeNested( `sync metadata session oracle through the persisted wrapper: nesting and seeds`, () => { for (const { - lane, - property: lanePropertyName, + productionPath, + property: productionPathPropertyName, seed, runs, reach: states, - } of extraLanes) { - const runLane = (history: Array) => - assertPersistedHistory(history, lane) + } of extraProductionPaths) { + const runProductionPath = (history: Array) => + assertPersistedHistory(history, productionPath) if (requestedReplayProperty === undefined) { - const reach = createOracleReach(`${lanePropertyName} (fixed)`, states, { - minimum: 5, - }) - fcTest.prop([sessionHistoryArbitrary(lane)], { + const reach = createOracleReach( + `${productionPathPropertyName} (fixed)`, + states, + { + minimum: 5, + }, + ) + fcTest.prop([sessionHistoryArbitrary(productionPath)], { numRuns: oracleRuns(runs), seed, })( - `keeps the session law on ${lane.name} histories (fixed)`, + `keeps the session law on ${productionPath.name} histories (fixed)`, (history) => { reach.beginHistory() - for (const regime of historyRegimes(lane, history)) + for (const regime of historyRegimes(productionPath, history)) if (states.includes(regime)) reach.reach(regime) - return runLane(history) + return runProductionPath(history) }, 240_000, ) - it(`reaches the declared states in the ${lane.name} fixed campaign`, () => + it(`reaches the declared states in the ${productionPath.name} fixed campaign`, () => reach.assertReached()) fcTest.prop( - [sessionHistoryArbitrary(lane)], - oraclePropertyOptions(50, lanePropertyName), + [sessionHistoryArbitrary(productionPath)], + oraclePropertyOptions(50, productionPathPropertyName), )( - `keeps the session law on ${lane.name} histories (random)`, - runLane, + `keeps the session law on ${productionPath.name} histories (random)`, + runProductionPath, 120_000, ) - } else if (requestedReplayProperty === lanePropertyName) { + } else if (requestedReplayProperty === productionPathPropertyName) { fcTest.prop( - [sessionHistoryArbitrary(lane)], - oraclePropertyOptions(50, lanePropertyName), - )(`replays ${lane.name} session histories`, runLane, 120_000) + [sessionHistoryArbitrary(productionPath)], + oraclePropertyOptions(50, productionPathPropertyName), + )( + `replays ${productionPath.name} session histories`, + runProductionPath, + 120_000, + ) } } }, @@ -891,15 +900,15 @@ for (const campaign of [`fixed`, `random`] as const) { await runKeptMetadataDurabilityCampaign({ property: durabilityProperty, campaign, - runHistory: (history, lane) => - runSessionHistory(lane, history, () => + runHistory: (history, productionPath) => + runSessionHistory(productionPath, history, () => createPersistedDriver( true, true, false, true, - lane.fullRows === true, - lane.onDemand === true, + productionPath.fullRows === true, + productionPath.onDemand === true, ), ), }) @@ -929,7 +938,7 @@ for (const campaign of [`fixed`, `random`] as const) { for (const { bare, held, history } of batch) { reach.beginHistory() const result = await runSessionHistory( - persistedPeerLane, + persistedPeerGrammar, history, () => createPersistedDriver(true, true, false, true), ) @@ -952,7 +961,7 @@ for (const campaign of [`fixed`, `random`] as const) { requireExecutedHistory(queuedRepairProperty, result) if (result.failed) throw new Error( - `${result.error}\nseed=${result.seed} path=${result.counterexamplePath}`, + `${String(result.errorInstance)}\nseed=${result.seed} path=${result.counterexamplePath}`, ) reach.assertReached() }, 120_000) diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts index 8880680f46..2bb9a2d948 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-driver.ts @@ -44,7 +44,7 @@ import type { import type { SyncConfig } from '@tanstack/db' import type { ReceiptState, - SessionLane, + SessionGrammar, } from '../../db/tests/sync-metadata-session-oracle' type Row = { id: number; v: unknown } @@ -57,7 +57,7 @@ let run = 0 * before comparison; returned recovery inputs contain no expected metadata. */ export async function createSQLiteDriver( - lane: SessionLane, + productionPath: SessionGrammar, ): Promise { const database = new DatabaseSync(`:memory:`) const { driver, drained } = createMetadataSQLiteDriver(database) @@ -69,7 +69,7 @@ export async function createSQLiteDriver( new SQLiteCorePersistenceAdapter({ driver }), ] const id = `metadata-items` - const seedClaim = lane.onDemand + const seedClaim = productionPath.onDemand ? await adapters[0]!.claimCacheGeneration(id) : undefined const target = seedClaim @@ -110,7 +110,7 @@ export async function createSQLiteDriver( await heldRecovery.gate } } - const pullSince = coordinators[0]!.pullSince!.bind(coordinators[0]) + const pullSince = coordinators[0]!.pullSince.bind(coordinators[0]) coordinators[0]!.pullSince = async (...args) => { const response = await pullSince(...args) if (response.ok && (response.requiresFullReload || !response.deltas)) { @@ -258,15 +258,19 @@ export async function createSQLiteDriver( persistedCollectionOptions({ id, getKey: (row) => row.id, - ...(lane.onDemand ? { syncMode: `on-demand` as const } : {}), + ...(productionPath.onDemand + ? { syncMode: `on-demand` as const } + : {}), sync: { - rowUpdateMode: lane.fullRows ? `full` : `partial`, + rowUpdateMode: productionPath.fullRows ? `full` : `partial`, sync: (sync) => { actions[index] = sync sync.begin() sync.commit() sync.markReady() - return lane.onDemand ? { loadSubset: () => true } : undefined + return productionPath.onDemand + ? { loadSubset: () => true } + : undefined }, }, onInsert: () => @@ -284,10 +288,10 @@ export async function createSQLiteDriver( // These direct source histories have no live query to start on-demand sync. // Start both sources before a follower can route its first demand to its peer. for (const current of collections) current.startSyncImmediate() - if (!lane.onDemand) + if (!productionPath.onDemand) await Promise.all(collections.map((collection) => collection.preload())) const demands = collections.map(() => ({})) - if (lane.onDemand) + if (productionPath.onDemand) await Promise.all( collections.map((collection, index) => collection._sync.loadSubset(demands[index]!), @@ -487,7 +491,7 @@ export async function createSQLiteDriver( `the real coordinator did not deliver the ${notice} notice`, ) console.info( - `SQLITE_METADATA_NOTICE ${JSON.stringify({ lane: lane.name, notice, received: notices.length - before })}`, + `SQLITE_METADATA_NOTICE ${JSON.stringify({ lane: productionPath.name, notice, received: notices.length - before })}`, ) }, observe: () => ({ @@ -539,7 +543,7 @@ export async function createSQLiteDriver( await release() while (depth) abort() await Promise.all(receipts) - if (lane.onDemand) + if (productionPath.onDemand) for (const [index, current] of collections.entries()) current._sync.unloadSubset(demands[index]!) await quiesce() diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts index 9cc53b0c75..42e8e570f9 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite.test.ts @@ -2,9 +2,9 @@ import { it } from 'vitest' import { describeMismatch, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, runSessionHistory, seedReinsertReloadReplay, } from '../../db/tests/sync-metadata-session-oracle' @@ -19,21 +19,26 @@ import { import { createSQLiteDriver } from './sync-metadata-session-sqlite-driver' import type { SessionAction, - SessionLane, + SessionGrammar, } from '../../db/tests/sync-metadata-session-oracle' -const lanes = [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, +const productionPaths = [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ] -async function check(history: ReadonlyArray, lane: SessionLane) { - return runSessionHistory(lane, history, () => createSQLiteDriver(lane)) +async function check( + history: ReadonlyArray, + productionPath: SessionGrammar, +) { + return runSessionHistory(productionPath, history, () => + createSQLiteDriver(productionPath), + ) } -oracleReplayTest(it).each(lanes)( +oracleReplayTest(it).each(productionPaths)( `receives the seed/re-insert/reload replay through SQLite ($name)`, - async (lane) => { - const result = await check(seedReinsertReloadReplay, lane) + async (productionPath) => { + const result = await check(seedReinsertReloadReplay, productionPath) if (result.mismatch) throw new Error( describeMismatch(seedReinsertReloadReplay, result.mismatch), @@ -78,8 +83,8 @@ for (const campaign of [`fixed`, `random`] as const) { campaign, runHistory: check, extraStates: [recoveryState], - extraReached: (lane, result) => - lane.onDemand && + extraReached: (productionPath, result) => + productionPath.onDemand && result.recoveryInputs.some( (input) => input.fromRowVersion === 0 && input.latestRowVersion >= 2, ) diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts index 55b6cef0d6..ff937f170c 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-write-work.test.ts @@ -4,9 +4,9 @@ import { fc } from '@fast-check/vitest' import { keptMetadataDurabilityHistoriesArbitrary, modelCheckpoints, - persistedPeerFullLane, - persistedPeerLane, - persistedPeerOnDemandLane, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, queuedMetadataRepairHistoriesArbitrary, } from '../../db/tests/sync-metadata-session-oracle' import { persistedCollectionOptions } from '../src' @@ -46,9 +46,9 @@ it(`constructs every declared metadata premise as a legal history`, async () => history.filter((action) => action.type === `commit`), ).toHaveLength(2) expect(history.some((action) => action.type === `rowSet`)).toBe(true) - expect((await modelCheckpoints(persistedPeerLane, history)).size).toBe( - history.length, - ) + expect( + (await modelCheckpoints(persistedPeerGrammar, history)).size, + ).toBe(history.length) } } for (const batch of fc.sample(keptMetadataDurabilityHistoriesArbitrary, { @@ -87,12 +87,12 @@ it(`constructs every declared metadata premise as a legal history`, async () => ) const depth = prefix.filter((action) => action.type === `begin`).length expect(depth).toBe(write === `inner-reinsert` ? 2 : 1) - for (const lane of [ - persistedPeerLane, - persistedPeerFullLane, - persistedPeerOnDemandLane, + for (const productionPath of [ + persistedPeerGrammar, + persistedPeerFullGrammar, + persistedPeerOnDemandGrammar, ]) - expect((await modelCheckpoints(lane, history)).size).toBe( + expect((await modelCheckpoints(productionPath, history)).size).toBe( history.length, ) } diff --git a/packages/db-sqlite-persistence-core/tests/temporal-value-oracle.ts b/packages/db-sqlite-persistence-core/tests/temporal-value-oracle.ts index b048d9abdb..8c718247d8 100644 --- a/packages/db-sqlite-persistence-core/tests/temporal-value-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/temporal-value-oracle.ts @@ -182,4 +182,6 @@ export function temporalQueryCases(family: TemporalFamily) { ] } +// Owns nothing: this module holds the typed-value fixtures and query cases. The +// production path is driven by sqlite-temporal-value-oracle.test.ts, which declares it. declareOracle({ model: [temporalQueryCases] }) diff --git a/packages/db-sqlite-persistence-core/vite.config.ts b/packages/db-sqlite-persistence-core/vite.config.ts index 803780c28f..123baedc75 100644 --- a/packages/db-sqlite-persistence-core/vite.config.ts +++ b/packages/db-sqlite-persistence-core/vite.config.ts @@ -12,6 +12,14 @@ const config = defineConfig({ typecheck: { enabled: true, include: [`tests/**/*.test.ts`, `tests/**/*.test-d.ts`], + // These oracles register their tests at run time through + // oracleProperty, so the typecheck collector finds no suite in them. + // Vitest still type-checks them through the project's tsc run. + exclude: [ + `**/node_modules/**`, + `tests/collection-setup-reopen-oracle.test.ts`, + `tests/persisted-optimistic-history-oracle.property.test.ts`, + ], }, }, }) diff --git a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts index dbd38365cd..0375addc22 100644 --- a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts +++ b/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts @@ -23,6 +23,7 @@ import { } from '../../tests/indexed-db/cross-tab-oracle' import { runCampaign } from '../../tests/indexed-db/campaign' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, oracleRuns, @@ -365,7 +366,10 @@ async function nativeHistoryCampaign( oracleReplayTest(test)( `native authored histories preserve every publication (fixed)`, ({ context }) => - nativeHistoryCampaign(context, { seed: 1179, numRuns: oracleRuns(10) }), + nativeHistoryCampaign( + context, + oracleFixedSeed(1179, { numRuns: oracleRuns(10) }), + ), ) oracleReplayTest(test, nativeHistoryProperty)( `native authored histories preserve every publication (fresh)`, diff --git a/packages/db/package-tests/indexed-db/packed-consumer.test.ts b/packages/db/package-tests/indexed-db/packed-consumer.test.ts index d6377eaae8..c4bc000277 100644 --- a/packages/db/package-tests/indexed-db/packed-consumer.test.ts +++ b/packages/db/package-tests/indexed-db/packed-consumer.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node /** Published runtime smoke tests supplement portable-declarations.test.ts. * Actual tarballs and Node's ESM/CJS loaders judge export paths; a persisted - * row survives cleanup and a new descriptor. This fixed integration lane does + * row survives cleanup and a new descriptor. This fixed integration path does * not model Collection histories or claim every package-manager configuration. * e2e/indexed-db/packed-consumer.spec.ts runs the same consumer through a browser bundler. */ diff --git a/packages/db/package-tests/indexed-db/packed-consumer.ts b/packages/db/package-tests/indexed-db/packed-consumer.ts index c5da2955fd..6a427cd25b 100644 --- a/packages/db/package-tests/indexed-db/packed-consumer.ts +++ b/packages/db/package-tests/indexed-db/packed-consumer.ts @@ -1,7 +1,7 @@ /** Packaging mechanics only. Pack the candidate and its installed dependency * closure, then install those tarballs in a disposable consumer. File overrides - * keep this lane offline and deterministic; they never point at workspace source - * or dist directories. No build runs here: the package/CI lane builds first. + * keep this path offline and deterministic; they never point at workspace source + * or dist directories. No build runs here: the package/CI job builds first. */ import { execFileSync } from 'node:child_process' import { diff --git a/packages/db/package.json b/packages/db/package.json index d5c4569e51..0f52e7446b 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -23,7 +23,7 @@ "test": "vitest --run", "test:dist": "vitest run --config vitest.dist.config.ts", "test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts", - "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/query/compiler/group-by-pipeline-oracle.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/collection-gc-retention-oracle.property.test.ts tests/collection-gc-mutation-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-settlement-boundaries-oracle.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-cache-options-oracle.test.ts tests/mutation-confirmation-oracle.test.ts tests/mutation-handler-types-oracle.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-hydration-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/public-container-copy-oracle.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/virtual-row-fields-input-oracle.test.ts tests/query/virtual-row-fields-effect-oracle.test.ts tests/query/virtual-row-fields-types-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/load-subset-joint-replay-oracle.property.test.ts", + "test:oracles": "vitest --run --coverage.enabled=false tests/reverse-index-oracle.property.test.ts tests/query/compiler/in-evaluator-oracle.property.test.ts tests/query/compiler/group-by-pipeline-oracle.test.ts tests/cleanup-queue-oracle.property.test.ts tests/comparison-oracle.property.test.ts tests/duration-query-equality-oracle.test.ts tests/cursor-oracle.property.test.ts tests/index-update-oracle.property.test.ts tests/utils-oracle.property.test.ts tests/change-event-history-oracle.test.ts tests/index-suggestion-oracle.test.ts tests/btree-map-oracle.test.ts tests/paced-mutations-oracle.test.ts tests/db-client-hydration-authority-oracle.test.ts tests/local-storage-order-oracle.test.ts tests/local-storage-write-order-oracle.test.ts tests/local-storage-peers-oracle.test.ts tests/local-storage-value-fidelity-oracle.property.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/collection-gc-transaction-oracle.property.test.ts tests/collection-gc-retention-oracle.property.test.ts tests/collection-gc-mutation-oracle.property.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/transaction-ownership-oracle.property.test.ts tests/optimistic-history-oracle.property.test.ts tests/optimistic-history-publication-oracle.test.ts tests/optimistic-history-outcomes-oracle.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-index-value-oracle.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history-oracle.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-reentrancy-oracle.test.ts tests/collection-subscription-lifecycle-publication-oracle.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-cache-options-oracle.test.ts tests/mutation-confirmation-oracle.test.ts tests/mutation-handler-types-oracle.test.ts tests/live-query-observer-history-oracle.property.test.ts tests/live-query-observer-hydration-oracle.property.test.ts tests/live-query-observer-snapshot-oracle.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/identity-output-shape-oracle.test.ts tests/query/optimizer-semantics-oracle.test.ts tests/query/index-path-collision-oracle.test.ts tests/query/custom-index-order-selection-oracle.test.ts tests/query/mixed-index-order-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/public-container-copy-oracle.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-alias-shadowing-oracle.test.ts tests/query/includes-outer-join-route-work-oracle.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/subquery-user-value-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state-oracle.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts tests/query/virtual-row-fields-oracle.test.ts tests/query/virtual-row-fields-input-oracle.test.ts tests/query/virtual-row-fields-effect-oracle.test.ts tests/query/virtual-row-fields-types-oracle.test.ts tests/query/where-predicate-publication-oracle.property.test.ts tests/query/join-result-key-oracle.property.test.ts tests/query/pooled-live-query-oracle.property.test.ts tests/live-query-deferred-acquisition-oracle.test.ts tests/flat-change-tracking-oracle.property.test.ts tests/truncate-readiness-oracle.test.ts tests/proxy-detachment-contract-oracle.test.ts tests/production-error-messages-oracle.test.ts tests/query/bucket-facade-rollback-oracle.property.test.ts tests/query/aggregate-select-oracle.property.test.ts tests/query/load-subset-joint-replay-oracle.property.test.ts", "bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run --coverage.enabled=false --reporter=verbose", "test:indexed-db:package": "pnpm --filter @tanstack/db... build && vitest run --config vitest.indexed-db.package.config.ts", "test:collection-gc:browser": "playwright test --config playwright.collection-gc.config.ts", @@ -64,6 +64,7 @@ "devDependencies": { "@playwright/test": "^1.65.0", "@tanstack/config": "^0.22.2", + "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610", "@vitest/coverage-istanbul": "^5.1.0", "arktype": "^2.2.9", "fake-indexeddb": "^6.2.8", diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index ff2fe4e175..6d0cfd34e8 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -92,6 +92,8 @@ type CollectionSubscriptionOptions = { onResumeAcquisition?: () => () => void /** The live query or Effect this subscription reads for. */ owner?: CollectionDependent + /** The subscriber drops its rows when the Collection is cleaned up. */ + discardsRowsOnCleanup?: boolean } type TruncateReplayPublicationControl = NonNullable< @@ -381,7 +383,16 @@ export class CollectionSubscription this.truncateReplacementPending = holdReplacement // Cleanup discards the rows without publishing deletes, so the published // rows go stale. A reload keeps the rows: only rows already stale stay so. - if (!reload) this.stalePublishedRows = new Map(this.publishedRows) + // A subscriber that drops its rows with the Collection, as a live-query + // observer's `cleaned-up` view does, holds none: a later write of an + // equal row is new to it, not a duplicate of its last publication. + if (!reload) { + if (this.options.discardsRowsOnCleanup) { + this.publishedRows = new Map() + this.sentKeys = new Set() + this.stalePublishedRows = new Map() + } else this.stalePublishedRows = new Map(this.publishedRows) + } this.pendingLoadSubsetParticipants.clear() for (const demand of [...this.subsetDemands]) { diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 47f4354b02..8b7f4fc830 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -616,7 +616,12 @@ class LiveQueryObserverImpl< } const subscription = collection.subscribeChanges( (changes) => notify(changes as Array>), - { includeInitialState: !this.wholesale && !attachedWithHydrationSeed }, + { + includeInitialState: !this.wholesale && !attachedWithHydrationSeed, + // The `cleaned-up` snapshot shows no rows, so the observer keeps none + // of the last sync run's rows for the next one. + discardsRowsOnCleanup: true, + }, ) ownRelease(() => subscription.unsubscribe()) this.blockDelivery = false diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index 0024c1823c..cf1b7bf2c3 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -352,7 +352,7 @@ export function processGroupBy( // D2 consolidates contributions that hash equal, and the hash treats -0 as 0 // and equal Dates as one value. Today it keeps the instance it was last given; - // db-ivm's groupBy tests pin that as current behavior, not as a promise. A min + // db-ivm's groupBy tests assert that as current behavior, not as a promise. A min // or max returns one of its inputs, so each contribution carries the exact key // of the value it compares, as a group value does. A primitive's key is its // exact value, so equal primitives still consolidate. An object's key holds its diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index a57b2abaea..24232dd485 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -440,8 +440,8 @@ produce. The exported `compileQuery` receiver checks both compilation orders through FROM, JOIN and union placement at initial graph drain. Its raw output comparison is differential and supplies no independent Effect-root attribution policy. Optimizer cloning can prevent an original raw-key cache hit in union -placement; that path checks propagation and transparency instead. -Separate static receiving-type gaps remain in the +placement; that path checks propagation and transparency instead. Separate +static receiving-type limits remain in the [deferred oracle record](../../../../../docs/contributing/oracle-reviews/2026-10-29-pr-2171-deferred-oracles.md). D2 hashes enumerable symbol keys and uses exact local-symbol identity plus @@ -946,6 +946,9 @@ next public batch reconciles retained subscriber rows against the installed state, including deletions for keys that do not return. An empty ready batch also reconciles an empty replacement. On-demand sources cannot infer absence from their partial installed state; their replay barrier owns replacement. +A live-query observer's subscription retains no rows across cleanup: the +observer's `cleaned-up` snapshot shows none, so every row of the next sync +run, including one equal to a row it showed before, reaches it as new. ### Source cancellation and applied settlement @@ -2095,10 +2098,10 @@ expected-failure guard for a planner or ownership defect that this graph does not own. Run the DB oracle set with `pnpm test:oracles` from `packages/db`. Broad -properties use FastCheck's random seed, while structural matrices keep fixed -seeds so each run covers the same named cells. Increase both corpora with -`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER=10 pnpm test:oracles`. Preserve FastCheck's -reported seed and shrink path while reducing a failure. Replay a broad +properties draw a random campaign seed, while structural matrices use fixed-seed +campaigns so each run covers the same named cells. Increase both corpora with +`TANSTACK_DB_ORACLE_RUNS_MULTIPLIER=10 pnpm test:oracles`. Preserve the reported +campaign seed and shrink path while reducing a failure. Reseed a broad campaign with `TANSTACK_DB_ORACLE_SEED= pnpm test:oracles`, then add the smallest case as a deterministic regression trace. diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 8d17b2ae8e..5aac9e359b 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -1273,6 +1273,12 @@ export interface SubscribeChangesOptions< * @internal */ owner?: CollectionDependent + /** + * The subscriber drops the rows it was sent when the Collection is cleaned + * up, so cleanup retains none of them to reconcile later writes against. + * @internal + */ + discardsRowsOnCleanup?: boolean /** * Listener for subscription status changes. * Registered BEFORE any snapshot is requested, ensuring no status transitions are missed. diff --git a/packages/db/tests/SortedMap-oracle.test.ts b/packages/db/tests/SortedMap-oracle.test.ts index 037e3d4255..9bdd85cfa9 100644 --- a/packages/db/tests/SortedMap-oracle.test.ts +++ b/packages/db/tests/SortedMap-oracle.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { SortedMap } from '../src/SortedMap' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config' import { createOracleReach } from './oracle-reach' @@ -347,7 +350,11 @@ function withDeferredChange(actions: Array): Array { ] } +// What this oracle owns: the gap report judges every function in +// SortedMap.ts, and its reference tree within the package, by this file's +// tests. declareOracle({ + owns: [`src/SortedMap.ts`], check: [expectMap, expectView], model: [modelOrder, restoreBound], grammar: [withDeferredChange], @@ -382,6 +389,13 @@ describe.each([`key`, `ascending`, `descending`] as const)( ], { minimum: 5 }, ) + const parameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, property) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), + examples: fixedHistories.map((history) => [history]), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc @@ -416,12 +430,7 @@ describe.each([`key`, `ascending`, `descending`] as const)( return Promise.resolve() }, ), - { - ...(seed === undefined - ? oraclePropertyOptions(100, property) - : { seed, numRuns: oracleRuns(100) }), - examples: fixedHistories.map((history) => [history]), - }, + parameters, ) if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) console.log(property, seed ?? `random`, [...reach.counts()]) @@ -458,6 +467,7 @@ describe.each([`ascending`, `descending`] as const)( `unpromised order`, `prune observation`, ] as const)(`credits only a restoring comparison: %s`, (control) => { + oracleRoute(`calibration`) const state = `deferred-row-changed-before-restore` const reach = createOracleReach( `sorted-map.${order}.${control}`, diff --git a/packages/db/tests/SortedMap.test.ts b/packages/db/tests/SortedMap.test.ts index 0f434c0ab1..be6b9041ff 100644 --- a/packages/db/tests/SortedMap.test.ts +++ b/packages/db/tests/SortedMap.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for SortedMap: ordering edge cases, bulk-sync movement bounds, and - * key iteration details that pin hand-written results. The law that every + * key iteration details that fix hand-written results. The law that every * ordering observation must refine a full recomputation is owned by * SortedMap-oracle.test.ts. */ @@ -14,7 +14,7 @@ import type { SyncConfig } from '../src/types.js' import type { ModelKey, Row } from './sorted-map-support' // Supplemental examples for SortedMap. The generated law lives in -// SortedMap-oracle.test.ts; these pin scale, scan reuse, and public +// SortedMap-oracle.test.ts; these check scale, scan reuse, and public // Collection sync paths that its small histories do not reach. async function countDisplacedSlots( diff --git a/packages/db/tests/btree-map-oracle.test.ts b/packages/db/tests/btree-map-oracle.test.ts index fa9a81435a..5c2ea88371 100644 --- a/packages/db/tests/btree-map-oracle.test.ts +++ b/packages/db/tests/btree-map-oracle.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { BTree } from '../src/utils/btree.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -51,6 +54,7 @@ it.each([ ])( `preserves the Map across a split after %s accepted entries`, (size, rightKey) => { + oracleRoute(`case`) const insertedKey = size * 2 const tree = new BTree( (left, right) => @@ -71,6 +75,7 @@ it.each([ ) it(`accepts infinite comparison results that still provide an order`, () => { + oracleRoute(`fixed history`) const tree = new BTree((left, right) => left === right ? 0 : left < right ? -Infinity : Infinity, ) @@ -126,7 +131,7 @@ const mapHistoryCampaigns = [ { name: `fixed`, test: oracleReplayTest(it), - parameters: { numRuns: oracleRuns(100), seed: 20260930 }, + parameters: oracleFixedSeed(20260930, { numRuns: oracleRuns(100) }), }, { name: `random`, @@ -281,16 +286,24 @@ function expectRefinement( expectPair(tree.nextLowerPair(undefined), sorted[sorted.length - 1], model) } -declareOracle({ check: [expectRefinement], model: [stepModel] }) +// What this oracle owns: the gap report judges the B+ tree in +// utils/btree.ts by this file's tests. The refinement check and the Map model +// judge every public observation of the tree. +declareOracle({ + owns: [`src/utils/btree.ts`], + check: [expectRefinement], + model: [stepModel], +}) describe(`BTree Map oracle`, () => { // The 120,000-operation corpus takes about nine seconds with CI coverage. - // This fixed lane is a stable depth campaign, not random discovery. Keep + // This fixed-seed campaign is a stable depth campaign, not random discovery. Keep // every operation and assertion. This is not a five-second performance budget. it( `matches a Map oracle under random insert/delete/overwrite with small nodes`, { timeout: 30_000 }, () => { + oracleRoute(`fixed seed`) let seed = 12345 const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff @@ -338,11 +351,13 @@ describe(`BTree Map oracle`, () => { campaign.test( `matches complete Map cuts across shrinkable histories (${campaign.name})`, () => { + oracleRoute(oracleRouteOf(campaign.parameters)) fc.assert(mapHistoryProperty, campaign.parameters) }, ) it(`replays a shrunk Map mismatch with the reported seed and path`, () => { + oracleRoute(`calibration`) // A scanner that drops every row keeps the initial empty cut green. The // first retained put fails at the public scan checkpoint. const wrongScan = vi @@ -354,7 +369,7 @@ describe(`BTree Map oracle`, () => { numRuns: 100, }) expect(failed.failed).toBe(true) - expect(failed.error).toMatch(/expected/) + expect(String(failed.errorInstance)).toMatch(/expected/) if (failed.counterexamplePath === null) throw new Error(`Missing Map mismatch replay path`) const replay = fc.check(mapHistoryProperty, { @@ -363,7 +378,7 @@ describe(`BTree Map oracle`, () => { numRuns: 1, }) expect(replay.failed).toBe(true) - expect(replay.error).toMatch(/expected/) + expect(String(replay.errorInstance)).toMatch(/expected/) expect(replay.counterexample).toEqual(failed.counterexample) } finally { wrongScan.mockRestore() @@ -373,6 +388,7 @@ describe(`BTree Map oracle`, () => { it.each([4, 5, 6, 7, 8])( `preserves every cut through dense growth, retirement and reuse with node size %s`, (nodeSize) => { + oracleRoute(`fixed history`) const tree = new BTree((a, b) => a - b, nodeSize) const model: ReferenceModel = new Map() expectRefinement(tree, model, 0) @@ -412,6 +428,7 @@ describe(`BTree Map oracle`, () => { ) it(`rejects wrong pair payloads, shared payload mutation and transient stale state`, () => { + oracleRoute(`calibration`) const tree = new BTree((a, b) => a - b, 4) const value = { v: 1 } const model: ReferenceModel = new Map([ @@ -467,6 +484,7 @@ describe(`BTree Map oracle`, () => { scan(-Infinity, high, includeHigh, onFound)) as typeof scan, }, ])(`rejects a range scan that $design`, ({ wrap }) => { + oracleRoute(`calibration`) const tree = new BTree((a, b) => a - b, 4) const model: ReferenceModel = new Map() for (let key = 0; key < 40; key += 2) @@ -483,6 +501,7 @@ describe(`BTree Map oracle`, () => { }) it(`shrinks and replays a wrong neighbor payload without losing its pair key`, () => { + oracleRoute(`calibration`) const property = fc.property( fc.array(fc.integer({ min: 0, max: 20 }), { minLength: 1, maxLength: 8 }), (values) => { @@ -501,7 +520,7 @@ describe(`BTree Map oracle`, () => { ) const failed = fc.check(property, { seed: 303102, numRuns: 1 }) expect(failed.failed).toBe(true) - expect(failed.error).toMatch(/expected/) + expect(String(failed.errorInstance)).toMatch(/expected/) expect(failed.counterexample?.[0].length).toBeGreaterThan(0) if (failed.counterexamplePath === null) throw new Error(`Missing calibration replay path`) @@ -512,7 +531,7 @@ describe(`BTree Map oracle`, () => { endOnFailure: true, }) expect(replay.failed).toBe(true) - expect(replay.error).toMatch(/expected/) + expect(String(replay.errorInstance)).toMatch(/expected/) expect(replay.counterexample).toEqual(failed.counterexample) }) }) diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index 61fe073a5f..06a0d7ba42 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -10,7 +10,13 @@ import { } from '../src/index' import { localOnlyCollectionOptions } from '../src/local-only' import { PropRef } from '../src/query/ir' -import { oraclePropertyOptions, oracleRuns } from './oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config' import { declareOracle } from './oracle-declaration' import { captureCreatedTransactions } from './utils' import { @@ -49,7 +55,7 @@ import type { ChangeMessage, SyncConfig } from '../src/types' * through a local-only Collection, either in one same-turn batch or * sequentially. After all batched writes settle, or after each sequential * write settles, public rows and a change-message mirror must equal the - * reference rows. An eager-index lane also builds an index before the history + * reference rows. An eager-index grammar also builds an index before the history * and compares both equality buckets after each settled prefix in sequential * histories. In batched histories it compares them after each synchronous * operation before settlement, then once after all writes settle. At the @@ -583,7 +589,11 @@ function expectDurationFieldPublication( expect(fields(mirror.get(1))).toEqual(expectedFields) } +// What this oracle owns: the gap report judges the change-message publication +// in collection/changes.ts, and the direct writes of a local-only Collection in +// local-only.ts, by this file's tests. declareOracle({ + owns: [`src/collection/changes.ts`, `src/local-only.ts`], check: [ assertFinalAgreement, assertIndexAgreement, @@ -604,6 +614,7 @@ describe(`change-event history oracle`, () => { // This witness covers ordinary same-realm Durations, not altered objects or // a requirement to suppress events for a separately allocated equal copy. it(`publishes a Duration field change to the Collection and change-message consumer`, async () => { + oracleRoute(`fixed history`) let sync!: Parameters[`sync`]>[0] const collection = createCollection({ id: `duration-field-change-history`, @@ -669,6 +680,7 @@ describe(`change-event history oracle`, () => { }) it(`preserves each key's causal messages within a deferred callback`, async () => { + oracleRoute(`fixed history`) const rounds: ReadonlyArray> = [ [ { kind: `insert`, key: 1, step: 1 }, @@ -804,6 +816,7 @@ describe(`change-event history oracle`, () => { }) it(`rejects a deferred message with the right name but wrong row fields`, () => { + oracleRoute(`calibration`) const sequence: Array = [ { kind: `insert`, key: 1, step: 1 }, { kind: `update`, key: 1, step: 2 }, @@ -827,6 +840,7 @@ describe(`change-event history oracle`, () => { }) it(`rejects a missing live key in the index checker`, () => { + oracleRoute(`calibration`) expect(replacement.map(({ kind, key }) => [kind, key])).toEqual([ [`delete`, 1], [`delete`, 2], @@ -845,6 +859,7 @@ describe(`change-event history oracle`, () => { it.each([`batched`, `sequential`] as const)( `keeps ${name} and indexed queries aligned after %s replacement`, async (mode) => { + oracleRoute(`fixed history`) await runHistory( replacement, [1, 2], @@ -872,21 +887,32 @@ describe(`change-event history oracle`, () => { } } - fcTest.prop([indexedHistory], { numRuns: oracleRuns(35), seed: 1_912 })( + const indexedHistoryFixedSeed = oracleFixedSeed(1_912, { + numRuns: oracleRuns(35), + }) + fcTest.prop([indexedHistory], indexedHistoryFixedSeed)( `keeps eager indexes and indexed queries aligned across fixed-seed histories`, - verifyIndexedHistory, + async (history) => { + oracleRoute(oracleRouteOf(indexedHistoryFixedSeed)) + await verifyIndexedHistory(history) + }, ) - fcTest.prop( - [indexedHistory], - oraclePropertyOptions(35, `collection-state.eager-index-history`), - )( + const indexedHistoryCampaign = oraclePropertyOptions( + 35, + `collection-state.eager-index-history`, + ) + fcTest.prop([indexedHistory], indexedHistoryCampaign)( `keeps eager indexes and indexed queries aligned across random histories`, - verifyIndexedHistory, + async (history) => { + oracleRoute(oracleRouteOf(indexedHistoryCampaign)) + await verifyIndexedHistory(history) + }, ) it.each(cases)( `$id: $label reconstructs every public row`, async ({ mode, sequence, initialKeys, id }) => { + oracleRoute(`case`) await runHistory(sequence, initialKeys, mode, `history-oracle-${id}`) }, ) @@ -896,6 +922,7 @@ describe(`change-event history oracle`, () => { it.each(cases)( `$id: $label then reload reconstructs every public row`, async ({ mode, sequence, initialKeys, id }) => { + oracleRoute(`case`) await runHistory( sequence, initialKeys, @@ -908,16 +935,26 @@ describe(`change-event history oracle`, () => { }, ) - fcTest.prop([generatedHistory], { numRuns: oracleRuns(100), seed: 1_902 })( + const generatedHistoryFixedSeed = oracleFixedSeed(1_902, { + numRuns: oracleRuns(100), + }) + fcTest.prop([generatedHistory], generatedHistoryFixedSeed)( `keeps change messages and public rows aligned with a fixed seed`, - verifyGeneratedHistory, + async (history) => { + oracleRoute(oracleRouteOf(generatedHistoryFixedSeed)) + await verifyGeneratedHistory(history) + }, ) - fcTest.prop( - [generatedHistory], - oraclePropertyOptions(100, `collection-state.change-event-history`), - )( + const generatedHistoryCampaign = oraclePropertyOptions( + 100, + `collection-state.change-event-history`, + ) + fcTest.prop([generatedHistory], generatedHistoryCampaign)( `keeps change messages and public rows aligned with a random or replayed seed`, - verifyGeneratedHistory, + async (history) => { + oracleRoute(oracleRouteOf(generatedHistoryCampaign)) + await verifyGeneratedHistory(history) + }, ) }) diff --git a/packages/db/tests/cleanup-queue-oracle.property.test.ts b/packages/db/tests/cleanup-queue-oracle.property.test.ts index 834280bf1d..584a417a23 100644 --- a/packages/db/tests/cleanup-queue-oracle.property.test.ts +++ b/packages/db/tests/cleanup-queue-oracle.property.test.ts @@ -1,7 +1,13 @@ import fc from 'fast-check' import { expect, it, vi } from 'vitest' import { CleanupQueue } from '../src/collection/cleanup-queue' -import { oraclePropertyOptions, oracleRuns } from './oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config' import { declareOracle } from './oracle-declaration' import { resetCleanupQueue } from './utils' @@ -124,7 +130,13 @@ function expectQueueState( ).toEqual([...model.errors].sort()) } -declareOracle({ check: [expectQueueState], model: [stepModel] }) +// What this oracle owns: the gap report judges every function in +// cleanup-queue.ts by this file's tests. +declareOracle({ + owns: [`src/collection/cleanup-queue.ts`], + check: [expectQueueState], + model: [stepModel], +}) async function runHistory(actions: Array, fault: Fault = `none`) { vi.useFakeTimers() @@ -247,66 +259,68 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( // grammar; a negative elapsed advance is invalid for its monotonic elapsed // clock. The fixed seed and seedless campaigns use the same property and run // budget. The shared oracle config -// accepts a seed and shrink path for direct replay of the seedless lane. +// accepts a seed and shrink path for direct replay of the seedless grammar. it.each([20260913, undefined])( `obeys appointment histories (seed %s)`, async (seed) => { - await fc.assert( - fc.asyncProperty( - fc.array(actionArbitrary, { maxLength: 50 }), - (actions) => runHistory(actions), - ), - { - ...(seed === undefined - ? oraclePropertyOptions(100, `cleanup-queue.history`) - : { seed, numRuns: oracleRuns(100) }), - examples: [ + const parameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, `cleanup-queue.history`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), + examples: [ + [ [ - [ - { kind: `schedule`, key: 0, delay: 10, throws: false }, - { kind: `stepWallClock`, offset: -1000 }, - // At 9 the callback is early; at 10 it is due. - { kind: `advance`, elapsed: 9 }, - { kind: `advance`, elapsed: 1 }, - ], + { kind: `schedule`, key: 0, delay: 10, throws: false }, + { kind: `stepWallClock`, offset: -1000 }, + // At 9 the callback is early; at 10 it is due. + { kind: `advance`, elapsed: 9 }, + { kind: `advance`, elapsed: 1 }, ], + ], + [ [ - [ - { kind: `schedule`, key: 0, delay: 10, throws: false }, - { kind: `stepWallClock`, offset: 1000 }, - { kind: `schedule`, key: 1, delay: 20, throws: false }, - { kind: `advance`, elapsed: 0 }, - // The wall step cannot shift this elapsed-time boundary. - { kind: `advance`, elapsed: 9 }, - { kind: `advance`, elapsed: 1 }, - ], + { kind: `schedule`, key: 0, delay: 10, throws: false }, + { kind: `stepWallClock`, offset: 1000 }, + { kind: `schedule`, key: 1, delay: 20, throws: false }, + { kind: `advance`, elapsed: 0 }, + // The wall step cannot shift this elapsed-time boundary. + { kind: `advance`, elapsed: 9 }, + { kind: `advance`, elapsed: 1 }, ], + ], + [ [ - [ - { kind: `schedule`, key: 0, delay: 1, throws: false }, - { kind: `schedule`, key: 0, delay: 4, throws: true }, - { kind: `schedule`, key: 1, delay: 4, throws: false }, - { kind: `schedule`, key: 2, delay: 2, throws: false }, - { kind: `cancel`, key: 2 }, - { kind: `advance`, elapsed: 3 }, - { kind: `advance`, elapsed: 1 }, - { kind: `schedule`, key: 0, delay: 0, throws: false }, - { kind: `advance`, elapsed: 0 }, - ], + { kind: `schedule`, key: 0, delay: 1, throws: false }, + { kind: `schedule`, key: 0, delay: 4, throws: true }, + { kind: `schedule`, key: 1, delay: 4, throws: false }, + { kind: `schedule`, key: 2, delay: 2, throws: false }, + { kind: `cancel`, key: 2 }, + { kind: `advance`, elapsed: 3 }, + { kind: `advance`, elapsed: 1 }, + { kind: `schedule`, key: 0, delay: 0, throws: false }, + { kind: `advance`, elapsed: 0 }, ], + ], + [ [ - [ - { kind: `schedule`, key: 0, delay: 0, throws: false }, - { kind: `schedule`, key: 1, delay: 0, throws: false }, - { kind: `schedule`, key: 2, delay: 20, throws: false }, - { kind: `schedule`, key: 3, delay: 20, throws: false }, - { kind: `advance`, elapsed: 0 }, - { kind: `cancel`, key: 2 }, - { kind: `advance`, elapsed: 20 }, - ], + { kind: `schedule`, key: 0, delay: 0, throws: false }, + { kind: `schedule`, key: 1, delay: 0, throws: false }, + { kind: `schedule`, key: 2, delay: 20, throws: false }, + { kind: `schedule`, key: 3, delay: 20, throws: false }, + { kind: `advance`, elapsed: 0 }, + { kind: `cancel`, key: 2 }, + { kind: `advance`, elapsed: 20 }, ], ], - }, + ], + } + oracleRoute(oracleRouteOf(parameters)) + await fc.assert( + fc.asyncProperty( + fc.array(actionArbitrary, { maxLength: 50 }), + (actions) => runHistory(actions), + ), + parameters, ) }, ) @@ -315,6 +329,7 @@ it.each([20260913, undefined])( it.each([`ignore-cancel`, `lose-replacement`, `duplicate`, `late`] as const)( `rejects the %s faulty queue`, async (fault) => { + oracleRoute(`calibration`) await expect( runHistory( [ diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index 9dfd145f60..cf64be576f 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -9,6 +9,7 @@ import { import { createDeferred } from '../src/deferred' import { Query } from '../src/query/builder/index.js' import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import { flushPromises } from './utils' @@ -54,7 +55,7 @@ import type { Row } from './collection-cleanup-restart-support.js' * Errored eager and on-demand source histories clean up the dependent first, * then restart both Collections through live-query preload. They check the * same live-query object and subscriber after a later source update. These - * pinned paths sit outside the three-cut model; they do not establish + * fixed histories sit outside the three-cut model; they do not establish * automatic recovery after source-only restart. * If adapter cleanup and local teardown both fail, the aggregate keeps the * adapter error primary and every local error as an ordered secondary @@ -92,7 +93,7 @@ import type { Row } from './collection-cleanup-restart-support.js' * points. Refinement checks run before releasing the controlled adapter gate * and after the cleanup promise settles. Counts, exact errors, status, rows, * subscription and observer ownership, and settlement are observed. The alias - * and late-registration cases are pinned refinements outside the three-cut + * and late-registration cases are fixed-history refinements outside the three-cut * model. This suite does not establish full demand/replay histories, transport * shutdown, persistence-wrapper behavior, or general row-publication laws. */ @@ -291,7 +292,17 @@ function expectedDependentCleanup( } // Declared after the dependents grammar, whose cells are a const. +// What this oracle owns: the gap report judges public Collection cleanup and +// the lifecycle manager behind it, the terminal error of a pooled live query +// that depends on a cleaning Collection, and the disposal of an Effect at +// cleanup start, by this file's tests. declareOracle({ + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], model: [expectedCleanupBoundary, expectedDependentCleanup], grammar: [ reentryScenarios, @@ -315,6 +326,7 @@ describe(`Collection cleanup with failing dependents oracle`, () => { afterAll(() => reach.assertReached()) it(`declares each cell of the dependents product once`, () => { + oracleRoute(`case`) const ids = dependentCleanupCells.map(({ id }) => id) expect(new Set(ids).size).toBe(ids.length) // 6 x 6 pairs x 3 adapters, plus a second release order for the 20 pairs @@ -325,6 +337,7 @@ describe(`Collection cleanup with failing dependents oracle`, () => { it.each(dependentCleanupCells)( `settles after every dependent and the source's own cleanup: $id`, async (cell) => { + oracleRoute(`case`) reach.beginHistory() const expected = expectedDependentCleanup(cell) const gates = new Map>>() @@ -574,6 +587,7 @@ describe(`Collection cleanup admission oracle`, () => { it.each(subscriberAdmissionCases)( `closes $admission admission during $priorStatus $syncMode cleanup`, async ({ priorStatus, syncMode, admission }) => { + oracleRoute(`case`) const cleanupGate = createDeferred() let starts = 0 let acquisitions = 0 @@ -680,6 +694,7 @@ describe(`Collection cleanup admission oracle`, () => { ) it(`calibrates every declared bounded-product cell exactly once`, () => { + oracleRoute(`case`) expectExactCaseIds(reentryScenarios, [ `abort:plain:1`, `abort:plain:2`, @@ -699,6 +714,7 @@ describe(`Collection cleanup admission oracle`, () => { }) it(`waits for source cleanup before publishing the restart boundary`, async () => { + oracleRoute(`fixed history`) const cleanupGate = createDeferred() let starts = 0 let cleanups = 0 @@ -802,6 +818,7 @@ describe(`Collection cleanup admission oracle`, () => { }) it(`retains loading status at cleanup start, then admits restart`, async () => { + oracleRoute(`fixed history`) const cleanupGate = createDeferred() const statuses: Array = [] let starts = 0 @@ -863,6 +880,7 @@ describe(`Collection cleanup admission oracle`, () => { }) it(`rejects dependent preload when cleanup starts before terminal settlement`, async () => { + oracleRoute(`fixed history`) const cleanupGate = createDeferred() const loadGate = createDeferred() const loadEntered = createDeferred() @@ -928,6 +946,7 @@ describe(`Collection cleanup admission oracle`, () => { }) it(`disposes a dependent Effect when cleanup starts before terminal settlement`, async () => { + oracleRoute(`fixed history`) const cleanupGate = createDeferred() const handlerGate = createDeferred() const handlerEntered = createDeferred() @@ -1037,6 +1056,7 @@ describe(`Collection cleanup admission oracle`, () => { it.each(lateEffectCases)( `releases a late Effect during $priorStatus $syncMode cleanup of its $cleaningPosition source`, async ({ priorStatus, syncMode, cleaningPosition }) => { + oracleRoute(`case`) const cleanupGate = createDeferred() let starts = 0 const source = createCollection({ diff --git a/packages/db/tests/collection-gc-mutation-oracle.property.test.ts b/packages/db/tests/collection-gc-mutation-oracle.property.test.ts index 6e36107659..90a75b1e50 100644 --- a/packages/db/tests/collection-gc-mutation-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-mutation-oracle.property.test.ts @@ -10,10 +10,14 @@ import { stepGcModel, } from './collection-gc-retention-oracle.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from './oracle-config.js' +import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import { resetCleanupQueue, withOracleCleanup } from './utils.js' import type { GcAction, GcModel } from './collection-gc-retention-oracle.js' @@ -353,16 +357,30 @@ const historyArbitrary = fc.record({ age: fc.integer({ min: 0, max: 80 }), sourceValue: fc.integer({ min: -3, max: 3 }), }) +// What this oracle owns: the gap report judges the garbage-collection +// retention of Collections touched by a mutation, in lifecycle.ts, by this +// file's tests. The companion retention module declares the shared check and +// model. +declareOracle({ + owns: [`src/collection/lifecycle.ts`], + grammar: [historyArbitrary], +}) + it.each( - runConfig.replaySeed === undefined ? [20261026, undefined] : [undefined], + runConfig.campaignSeed === undefined ? [20261026, undefined] : [undefined], )( `retains every touched Collection across mutation shapes (seed %s)`, async (seed) => { reach = createOracleReach(propertyName, states) - await fc.assert(fc.asyncProperty(historyArbitrary, runMutationHistory), { + const parameters = { ...oraclePropertyOptions(150, propertyName), - ...(seed === undefined ? {} : { seed }), - }) + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(parameters)) + await fc.assert( + fc.asyncProperty(historyArbitrary, runMutationHistory), + parameters, + ) reach.assertReached() console.info( `GC mutation reach: ${JSON.stringify(Object.fromEntries(reach.counts()))}`, @@ -376,6 +394,7 @@ it.each( it.skipIf(oracleReplayRequested())( `rejects corruption of a supplied mutation projection`, () => { + oracleRoute(`calibration`) const model = createGcModel(100) stepGcModel(model, { kind: `mutate`, commit: true }) const observation = { diff --git a/packages/db/tests/collection-gc-retention-oracle.property.test.ts b/packages/db/tests/collection-gc-retention-oracle.property.test.ts index d227564d55..ecedf0ddad 100644 --- a/packages/db/tests/collection-gc-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-retention-oracle.property.test.ts @@ -4,10 +4,14 @@ import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { createTransaction } from '../src/transactions.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from './oracle-config.js' +import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import { resetCleanupQueue, withOracleCleanup } from './utils.js' import { @@ -496,7 +500,7 @@ const boundaryParameters = fc.record({ ), siblingPending: fc.boolean(), }) -const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( +const boundaryGrammarHistoryArbitrary: fc.Arbitrary = fc.oneof( ...[ `startup`, `unsubscribe`, @@ -513,7 +517,7 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( `async-cleanup-held`, `async-cleanup-retired`, `async-cleanup-single`, - ].map((lane) => + ].map((grammar) => boundaryParameters.map((parameters): BoundaryHistory => { const { gcTime, @@ -531,15 +535,15 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( siblingPending, } = parameters const actions: Array = [] - const asyncCleanup = lane.startsWith(`async-cleanup`) - // The two cleanup timing lanes always preserve a sibling. A separate - // single-transaction lane keeps the nearby no-survivor boundary. + const asyncCleanup = grammar.startsWith(`async-cleanup`) + // The two cleanup timing grammars always preserve a sibling. A separate + // single-transaction grammar keeps the nearby no-survivor boundary. const hasSibling = asyncCleanup - ? lane !== `async-cleanup-single` + ? grammar !== `async-cleanup-single` : sibling - const settleAfterCleanup = lane === `async-cleanup-retired` - const failedPreload = lane.startsWith(`failed-preload`) - const preloadLast = lane === `failed-preload-last` + const settleAfterCleanup = grammar === `async-cleanup-retired` + const failedPreload = grammar.startsWith(`failed-preload`) + const preloadLast = grammar === `failed-preload-last` const settleTransaction = (slot: number, result = outcome): GcAction => result === `rollback` ? { kind: `rollback`, slot } @@ -554,22 +558,22 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( actions.push({ kind: `preload` }) } } - if (lane === `warm-preload`) + if (grammar === `warm-preload`) actions.push( { kind: `advance`, elapsedMs: Math.max(gcTime, 50) }, { kind: `preload` }, ) - if (lane === `unsubscribe`) actions.push({ kind: `subscribe` }) - if (lane === `idle`) { + if (grammar === `unsubscribe`) actions.push({ kind: `subscribe` }) + if (grammar === `idle`) { actions.push({ kind: `advance`, elapsedMs: Math.max(gcTime, 50) + 1 }) for (let i = 0; i < noTimePeriods; i++) actions.push({ kind: `idle`, hasTime: false, didTimeout: false }) } actions.push({ kind: `insert`, - commit: !lane.startsWith(`pending`) && lane !== `delayed-commit`, + commit: !grammar.startsWith(`pending`) && grammar !== `delayed-commit`, }) - if (lane === `pending-subscribed`) { + if (grammar === `pending-subscribed`) { actions.push( { kind: `subscribe` }, { kind: `advance`, elapsedMs: Math.max(gcTime, 50) + age + 1 }, @@ -580,23 +584,23 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( { kind: `advance`, elapsedMs: 1 }, { kind: `idle`, hasTime: true, didTimeout: false }, ) - } else if (lane === `pending` || lane === `pending-alone`) { + } else if (grammar === `pending` || grammar === `pending-alone`) { actions.push({ kind: `advance`, elapsedMs: Math.max(gcTime, 50) - 1 }) - if (lane === `pending`) actions.push({ kind: `rollback`, slot: 0 }) + if (grammar === `pending`) actions.push({ kind: `rollback`, slot: 0 }) actions.push({ kind: `advance`, elapsedMs: 2 }) } else { - if (lane === `delayed-commit`) { + if (grammar === `delayed-commit`) { actions.push( { kind: `advance`, elapsedMs: age }, { kind: `commit`, slot: 0 }, ) } - if (lane === `preload` || failedPreload) preload() + if (grammar === `preload` || failedPreload) preload() if (hasSibling) actions.push({ kind: `insert`, commit: !pendingSibling }) - if (lane === `unsubscribe`) actions.push({ kind: `unsubscribe` }) + if (grammar === `unsubscribe`) actions.push({ kind: `unsubscribe` }) actions.push({ kind: `advance`, elapsedMs: age }) - if (lane === `warm-preload`) preload() + if (grammar === `warm-preload`) preload() if (asyncCleanup) actions.push({ kind: `cleanup-start` }) if (asyncCleanup && settleAfterCleanup) actions.push({ kind: `cleanup-settle` }) @@ -611,7 +615,7 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( ? { kind: `rollback`, slot: lastSlot } : settleTransaction(lastSlot, siblingOutcome), ) - if (lane === `preload`) actions.push({ kind: `ready` }) + if (grammar === `preload`) actions.push({ kind: `ready` }) if (failedPreload && preloadLast) actions.push({ kind: `preload-failure` }) if (asyncCleanup) @@ -625,15 +629,15 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( } actions.push({ kind: `idle`, hasTime: true, didTimeout: timeout }) // Test just below the startup floor and after every finite deadline. - if (lane === `startup` && age < 49) + if (grammar === `startup` && age < 49) actions.push( { kind: `advance`, elapsedMs: 49 - age }, { kind: `idle`, hasTime: true, didTimeout: false }, ) // Derive boundary cuts from the independent law, not production timers. const reference = createGcModel( - lane === `disabled` ? disabledTime : gcTime, - lane !== `preload` && !failedPreload, + grammar === `disabled` ? disabledTime : gcTime, + grammar !== `preload` && !failedPreload, ) for (const action of actions) if (gcActionLegal(reference, action)) stepGcModel(reference, action) @@ -652,9 +656,9 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( { kind: `cleanup` }, ) return { - gcTime: lane === `disabled` ? disabledTime : gcTime, - direct: direct && lane === `startup`, - ready: lane !== `preload` && !failedPreload, + gcTime: grammar === `disabled` ? disabledTime : gcTime, + direct: direct && grammar === `startup`, + ready: grammar !== `preload` && !failedPreload, asyncCleanup, actions, } @@ -666,7 +670,7 @@ const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( // The prefix establishes subscriber multiplicity, three persisting siblings, // a delayed commit, non-FIFO settlement and a warm preload between releases. // Generated suffixes then recombine those owners instead of only varying a -// predefined lane's parameters. A maximum of seven transaction slots and three +// predefined grammar's parameters. A maximum of seven transaction slots and three // subscribers keeps shrinking and exact-key observations small. Every emitted // action is legal; the production driver rejects a skipped interaction action. // Removing this arbitrary loses those crossings. Removing its suffix preserves @@ -846,23 +850,34 @@ const automaticCleanupHistoryArbitrary: fc.Arbitrary = fc }) const boundaryHistoryArbitrary: fc.Arbitrary = fc.oneof( - { arbitrary: boundaryLaneHistoryArbitrary, weight: 5 }, + { arbitrary: boundaryGrammarHistoryArbitrary, weight: 5 }, { arbitrary: interactionHistoryArbitrary, weight: 1 }, { arbitrary: automaticCleanupHistoryArbitrary, weight: 1 }, ) +// What this oracle owns: the gap report judges the garbage-collection +// retention of Collections, in lifecycle.ts, by this file's tests. The +// companion retention module declares the shared check and model that these +// histories are compared with. +declareOracle({ + owns: [`src/collection/lifecycle.ts`], + grammar: [boundaryHistoryArbitrary], +}) + it.each( - runConfig.replaySeed === undefined ? [20261026, undefined] : [undefined], + runConfig.campaignSeed === undefined ? [20261026, undefined] : [undefined], )( `preserves retention across transaction phases and idle delivery (seed %s)`, async (seed) => { boundaryReach = createOracleReach(boundaryPropertyName, boundaryStates) + const parameters = { + ...oraclePropertyOptions(250, boundaryPropertyName), + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(boundaryHistoryArbitrary, runBoundaryHistory), - { - ...oraclePropertyOptions(250, boundaryPropertyName), - ...(seed === undefined ? {} : { seed }), - }, + parameters, ) boundaryReach.assertReached() console.info( @@ -996,5 +1011,8 @@ const boundaryExamples: Array<{ name: string; history: BoundaryHistory }> = [ ] it.skipIf(oracleReplayRequested()).each(boundaryExamples)( `reconstructs $name`, - async ({ history }) => runBoundaryHistory(history), + async ({ history }) => { + oracleRoute(`fixed history`) + await runBoundaryHistory(history) + }, ) diff --git a/packages/db/tests/collection-gc-retention-oracle.test.ts b/packages/db/tests/collection-gc-retention-oracle.test.ts index 24e4990472..0540cb3512 100644 --- a/packages/db/tests/collection-gc-retention-oracle.test.ts +++ b/packages/db/tests/collection-gc-retention-oracle.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { oracleReplayRequested } from './oracle-config.js' +import { oracleReplayRequested, oracleRoute } from './oracle-config.js' import { checkGcObservation, createGcModel, @@ -7,6 +7,12 @@ import { } from './collection-gc-retention-oracle.js' import type { GcAction } from './collection-gc-retention-oracle.js' +// What this file owns: nothing in production. It is a checker calibration for +// the shared GC retention check in collection-gc-retention-oracle.ts, which +// declares the check and the model. Each case feeds that check a plausible +// wrong observation, and the check must reject it. The companion's production +// owners are the GC lifecycle and cleanup queue, judged by the property files. +// // Cleanup clears public rows even while a manual transaction remains pending // or its handler persists. The retention fixture has no durable source rows. // Feed the shared comparison a plausible broken cleanup result: status and @@ -32,6 +38,7 @@ it.skipIf(oracleReplayRequested()).each([ ] satisfies Array<{ name: string; actions: Array }>)( `rejects leftover optimistic rows after $name`, ({ actions }) => { + oracleRoute(`calibration`) const model = createGcModel(100) for (const action of actions) stepGcModel(model, action) expect(model.retired).toBe(true) @@ -58,6 +65,7 @@ it.skipIf(oracleReplayRequested()).each([ it.skipIf(oracleReplayRequested()).each([`complete`, `fail`] as const)( `rejects a stale row after handler outcome %s`, (outcome) => { + oracleRoute(`calibration`) const model = createGcModel(100) stepGcModel(model, { kind: `insert`, commit: true }) stepGcModel(model, { kind: `settle`, slot: 0, outcome }) @@ -92,6 +100,7 @@ it.skipIf(oracleReplayRequested()).each([`complete`, `fail`] as const)( it.skipIf(oracleReplayRequested())( `rejects missing, duplicate, and extra keys while transactions persist`, () => { + oracleRoute(`calibration`) const model = createGcModel(100) stepGcModel(model, { kind: `insert`, commit: true }) stepGcModel(model, { kind: `insert`, commit: true }) diff --git a/packages/db/tests/collection-gc-transaction-oracle.property.test.ts b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts index 3d5d61a43e..a024f71ae7 100644 --- a/packages/db/tests/collection-gc-transaction-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-transaction-oracle.property.test.ts @@ -3,7 +3,14 @@ import { expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { createTransaction } from '../src/transactions.js' -import { oraclePropertyOptions, readOracleRunConfig } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + readOracleRunConfig, +} from './oracle-config.js' +import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import { resetCleanupQueue, withOracleCleanup } from './utils.js' import type { Deferred } from '../src/deferred.js' @@ -398,23 +405,33 @@ const property = fc.asyncProperty( (history) => runHistory(history), ) +// What this oracle owns: the garbage-collection hold that a persisting +// transaction places on each Collection it touches, in lifecycle.ts. The +// cleanup-queue oracle owns the timer dispatch that runs the deadline. +declareOracle({ + owns: [`src/collection/lifecycle.ts`], + grammar: [stepArbitrary], +}) + // Replay one history with TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, // and TANSTACK_DB_ORACLE_PROPERTY=collection-lifecycle.gc-persisting-transaction. it.each( - runConfig.replaySeed === undefined ? [20261024, undefined] : [undefined], + runConfig.campaignSeed === undefined ? [20261024, undefined] : [undefined], )( `retires a Collection only after gcTime with no subscriber and no persisting transaction (seed %s)`, async (seed) => { reach = createReach() - await fc.assert(property, { + const parameters = { ...oraclePropertyOptions( 100, `collection-lifecycle.gc-persisting-transaction`, ), ...(seed === undefined || runConfig.replayPath !== undefined ? {} - : { seed }), - }) + : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(parameters)) + await fc.assert(property, parameters) reach.assertReached() console.info( `GC transaction reach: ${JSON.stringify(Object.fromEntries(reach.counts()))}`, diff --git a/packages/db/tests/collection-index-value-oracle.test.ts b/packages/db/tests/collection-index-value-oracle.test.ts index 5653916fc3..d0599d0d1b 100644 --- a/packages/db/tests/collection-index-value-oracle.test.ts +++ b/packages/db/tests/collection-index-value-oracle.test.ts @@ -19,6 +19,7 @@ import { afterEach, expect, it } from 'vitest' import { Temporal } from 'temporal-polyfill' import { IR, coalesce } from '../src' import { collections, createSource } from './collection-index-value-fixture.js' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' afterEach(async () => { @@ -43,9 +44,16 @@ function expectSignatureClasses( } } -declareOracle({ check: [expectSignatureClasses] }) +// What this oracle owns: the gap report judges the index metadata and its +// signatures in collection/indexes.ts by this file's tests. The signature +// check compares every fixture pair's class with its signature. +declareOracle({ + owns: [`src/collection/indexes.ts`], + check: [expectSignatureClasses], +}) it(`distinguishes native literals from ordinary tags in index signatures`, () => { + oracleRoute(`case`) const instant = Temporal.Instant.from(`2026-01-02T00:00:00Z`) const date = Temporal.PlainDate.from(`2026-01-02`) const tag = { __type: `Temporal.Instant`, value: String(instant) } 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 f962d8ce50..825d0058e3 100644 --- a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts @@ -6,9 +6,12 @@ import { SyncTransactionAbortedError } from '../src/errors.js' import { createLiveQueryCollection } from '../src/query/index.js' import { createTransaction } from '../src/transactions.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -576,6 +579,7 @@ const mutateDriverMetadata: MetadataDriver = (value) => { } it(`detects driver mutation of committed metadata without rewriting its authority`, async () => { + oracleRoute(`calibration`) const rounds: Array = [ { key: 1, @@ -597,6 +601,7 @@ it(`detects driver mutation of committed metadata without rewriting its authorit }) it(`rejects a torn callback-time metadata cut even when settled metadata is correct`, async () => { + oracleRoute(`calibration`) const rounds: Array = [ { key: 1, @@ -620,6 +625,7 @@ it.each( )( `detects $placement metadata mutation with canceledFirst=$canceledFirst`, async ({ placement, canceledFirst }) => { + oracleRoute(`calibration`) const initial: Array = placement === `initial` ? [ @@ -652,6 +658,7 @@ it.each( ) it(`shrinks and replays a driver-only metadata corruption`, async () => { + oracleRoute(`calibration`) const roundsFor = (nested: number): Array => [ { key: 1, @@ -681,6 +688,7 @@ it(`shrinks and replays a driver-only metadata corruption`, async () => { }) it(`publishes one event per key when metadata-only sync retires optimistic work`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { key: 1, @@ -698,6 +706,7 @@ it(`publishes one event per key when metadata-only sync retires optimistic work` }) it(`applies the last committed metadata write and discards aborted writes`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { key: 1, @@ -722,6 +731,7 @@ it(`applies the last committed metadata write and discards aborted writes`, asyn }) it(`releases only canceled metadata keys while another sync remains pending`, async () => { + oracleRoute(`fixed history`) await expectMetadataCancellationOwnership( [0, 1], [1, 2], @@ -737,6 +747,7 @@ it(`releases only canceled metadata keys while another sync remains pending`, as }) it(`does not apply canceled metadata to an absent base key`, async () => { + oracleRoute(`fixed history`) await expectMetadataCancellationOwnership( [0, 1], [1, 2], @@ -748,6 +759,7 @@ it(`does not apply canceled metadata to an absent base key`, async () => { }) it(`settles an older metadata owner after canceling the newer owner`, async () => { + oracleRoute(`fixed history`) await expectMetadataCancellationOwnership( [0, 1], [1, 2], @@ -789,40 +801,66 @@ const runCancellationHistory = ({ initialMetadata, ) -declareOracle({ check: [applyRound, expectMetadataCancellationOwnership] }) +// What this oracle owns: the gap report judges the metadata-only sync +// transaction and its metadata writes in sync.ts, and the publication of +// row and metadata changes to subscribers in state.ts, by this file's tests. +declareOracle({ + owns: [`src/collection/sync.ts`, `src/collection/state.ts`], + check: [applyRound, expectMetadataCancellationOwnership], +}) describe(`generated metadata publication histories`, () => { if (!oracleReplayRequested()) { - fcTest.prop([publicationHistoryArbitrary], { + const publicationHistoryFixedSeed = oracleFixedSeed(1_805_001, { numRuns: oracleRuns(50), - seed: 1_805_001, - })( + }) + fcTest.prop([publicationHistoryArbitrary], publicationHistoryFixedSeed)( `keeps metadata-only optimistic settlement a valid keyed diff (fixed)`, - runPublicationHistory, + async (rounds) => { + oracleRoute(oracleRouteOf(publicationHistoryFixedSeed)) + await runPublicationHistory(rounds) + }, ) - fcTest.prop([metadataCancellationArbitrary], { + const metadataCancellationFixedSeed = oracleFixedSeed(1_805_002, { numRuns: oracleRuns(50), - seed: 1_805_002, - })( + }) + fcTest.prop([metadataCancellationArbitrary], metadataCancellationFixedSeed)( `keeps metadata suppression owned by pending transactions (fixed)`, - runCancellationHistory, + async (history) => { + oracleRoute(oracleRouteOf(metadataCancellationFixedSeed)) + await runCancellationHistory(history) + }, ) } // A path replay of one property runs its one recorded history through that // property's registration. The other registration stands aside. + const publicationHistoryCampaign = oraclePropertyOptions( + 50, + metadataOnlyProperty, + ) oracleReplayTest(fcTest, metadataOnlyProperty).prop( [publicationHistoryArbitrary], - oraclePropertyOptions(50, metadataOnlyProperty), + publicationHistoryCampaign, )( `keeps metadata-only optimistic settlement a valid keyed diff (random)`, - runPublicationHistory, + async (rounds) => { + oracleRoute(oracleRouteOf(publicationHistoryCampaign)) + await runPublicationHistory(rounds) + }, + ) + const metadataCancellationCampaign = oraclePropertyOptions( + 50, + metadataCancellationProperty, ) oracleReplayTest(fcTest, metadataCancellationProperty).prop( [metadataCancellationArbitrary], - oraclePropertyOptions(50, metadataCancellationProperty), + metadataCancellationCampaign, )( `keeps metadata suppression owned by pending transactions (random)`, - runCancellationHistory, + async (history) => { + oracleRoute(oracleRouteOf(metadataCancellationCampaign)) + await runCancellationHistory(history) + }, ) }) diff --git a/packages/db/tests/collection-row-metadata-composition-oracle.test.ts b/packages/db/tests/collection-row-metadata-composition-oracle.test.ts index 80356d763c..4cbbaaed1c 100644 --- a/packages/db/tests/collection-row-metadata-composition-oracle.test.ts +++ b/packages/db/tests/collection-row-metadata-composition-oracle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { DbClient, collectionOptions } from '../src/client.js' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import type { SyncConfig } from '../src/types.js' @@ -11,7 +12,7 @@ import type { SyncConfig } from '../src/types.js' * through `metadata.row.set` and `metadata.row.delete`, through the `metadata` * field of a row message, and through `truncate`. Writes that carry metadata * replace earlier values, so the last such write wins. `collection.test.ts` - * pins that rule. A maintainer decision on 2026-10-05 (recorded in + * covers that rule with fixed examples. A maintainer decision on 2026-10-05 (recorded in * `docs/contributing/oracle-reviews/2026-10-05-state-mutation-round-3.md`) * fixes the writes that carry none: * @@ -26,7 +27,7 @@ import type { SyncConfig } from '../src/types.js' * absent row. * * This oracle covers one key, one transaction of up to three writes, and four - * lanes: immediate, held, rebuilt, and nested. The nested lane applies a + * variants: immediate, held, rebuilt, and nested. The nested variant applies a * nested transaction or a hydration seed before the open transaction. When a * rebuild changes an insert into an equal re-insert, or the reverse, the rule * follows the write that applies. The model judges the reclassified sequence. @@ -47,11 +48,11 @@ import type { SyncConfig } from '../src/types.js' * `collection.base` is a different observation: it is the applied view and * excludes queued writes. * - * The held-window lane checks this rule for `metadata.row.get`, + * The held-window variant checks this rule for `metadata.row.get`, * `metadata.collection.get`, and `metadata.collection.list`. The history's * explicit `set` and `unset` writes also write one collection key. A later * accepted transaction writes another key. A read sees both before and after - * the hold ends. The lane does not judge a read between writes of one open + * the hold ends. The variant does not judge a read between writes of one open * transaction, nested transactions, or wrapper-deferred transactions. */ @@ -66,7 +67,7 @@ type Write = metadata: boolean } type Start = `absent` | `present-with-metadata` | `present-without-metadata` -type Lane = `immediate` | `held` | `rebuilt` +type ProductionPath = `immediate` | `held` | `rebuilt` const writes: ReadonlyArray = [ { kind: `set` }, @@ -152,11 +153,11 @@ const histories = starts.flatMap((start) => { }) /** - * The rebuilt lane also varies the earlier held transaction. It either + * The rebuilt variant also varies the earlier held transaction. It either * updates key 2, or writes key 1 without metadata: an update or a delete when * key 1 is present, an insert when it is absent. A key-1 write changes the * projection that the open transaction rebuilds against, so its sequences are - * legal after that write. Sequences stay at two writes to bound the lane. + * legal after that write. Sequences stay at two writes to bound the variant. */ type EarlierWrite = { key: 1 | 2; kind: `insert` | `update` | `delete` } const shortSequences = writes.flatMap((first) => [ @@ -190,7 +191,7 @@ const rebuiltHistories = starts.flatMap((start) => { }) /** - * The nested lane opens a transaction, T1, and writes up to three writes to + * The nested variant opens a transaction, T1, and writes up to three writes to * key 1. Then a nested write applies before T1 commits: * * - `delete`: a transaction begun inside T1 deletes the present row. @@ -208,7 +209,7 @@ const rebuiltHistories = starts.flatMap((start) => { * T1 applies after the nested write, so the Collection reclassifies each of * T1's inserts against the rows that the nested write leaves. An insert of a * present key is an equal re-insert, and an insert of an absent key is an - * insert. Every row write in this lane writes one value, so a reclassified + * insert. Every row write in this variant writes one value, so a reclassified * insert is never a duplicate. The `held` variant holds both transactions * behind a persisting optimistic update until T1 commits; a hold delays * application but cannot change commit order, so it expects the same value. @@ -326,7 +327,8 @@ const describeWrite = (write: Write) => /** * The driver writes the history through a real Collection's sync API and reads * the result through `metadata.row.get` after the transaction applies. Each - * lane reaches a different production path for the same rule: + * production path takes a different route through the Collection for the same + * rule: * * - `immediate`: the transaction applies at commit. * - `held`: a persisting optimistic request holds the transaction until it @@ -335,10 +337,10 @@ const describeWrite = (write: Write) => * open, so the Collection rebuilds this transaction's automatic metadata * writes before it commits. */ -// The model predicts a lane's row metadata. A write before the history (the +// The model predicts a production path's row metadata. A write before the history (the // earlier write) applies first when it names the same key, and the history // then folds over that result. -function expectedLaneMetadata( +function expectedProductionPathMetadata( start: Start, sequence: ReadonlyArray, earlier: EarlierWrite | undefined, @@ -362,19 +364,23 @@ function compareObservedMetadata( ) } +// What this oracle owns: the gap report judges how a sync transaction's row +// metadata writes are applied and read, in sync.ts and state.ts, by this +// file's tests, across the immediate, held, and rebuilt production paths. declareOracle({ + owns: [`src/collection/sync.ts`, `src/collection/state.ts`], check: [compareObservedMetadata], model: [ expectedMetadata, foldMetadata, - expectedLaneMetadata, + expectedProductionPathMetadata, expectedNestedMetadata, ], grammar: [histories, rebuiltHistories, nestedHistories], }) async function observeMetadata( - lane: Lane, + productionPath: ProductionPath, start: Start, sequence: ReadonlyArray, id: string, @@ -409,13 +415,13 @@ async function observeMetadata( try { await collection.stateWhenReady() const blocker = - lane === `immediate` + productionPath === `immediate` ? undefined : collection.update(2, (draft) => { draft.v = 9 }) let earlierReceipt: true | Promise = true - if (lane === `rebuilt`) { + if (productionPath === `rebuilt`) { sync.begin() if (earlier.kind === `delete`) sync.write({ type: `delete`, key: 1 }) else @@ -427,7 +433,7 @@ async function observeMetadata( } // A re-insert writes the row exactly as the source holds it. let held: number | undefined = start === `absent` ? undefined : 0 - if (lane === `rebuilt` && earlier.key === 1) + if (productionPath === `rebuilt` && earlier.key === 1) held = earlier.kind === `delete` ? undefined : 100 sync.begin() sequence.forEach((write, step) => { @@ -457,15 +463,15 @@ async function observeMetadata( ...(write.metadata ? { metadata } : {}), }) }) - if (lane === `rebuilt`) { + if (productionPath === `rebuilt`) { expect(earlierReceipt).not.toBe(true) release() await blocker!.when(`settled`) await earlierReceipt } const receipt = sync.commit() - if (lane === `immediate`) expect(receipt).toBe(true) - if (lane === `held`) { + if (productionPath === `immediate`) expect(receipt).toBe(true) + if (productionPath === `held`) { // A truncate applies at once; the request holds every other transaction. if (sequence.some((write) => write.kind === `truncate`)) expect(receipt).toBe(true) @@ -746,6 +752,7 @@ async function observeNested( describe(`row metadata composition oracle`, () => { it(`enumerates every legal one-key history of up to three writes`, () => { + oracleRoute(`case`) expect(histories).toHaveLength(1457) const labels = new Set( histories.map( @@ -771,6 +778,7 @@ describe(`row metadata composition oracle`, () => { }) it(`rejects named wrong answers in the model`, () => { + oracleRoute(`calibration`) // A delete that carried metadata keeps none. expect( expectedMetadata(`present-with-metadata`, [ @@ -799,6 +807,7 @@ describe(`row metadata composition oracle`, () => { }) it(`enumerates rebuilt histories against every earlier write`, () => { + oracleRoute(`case`) // 1457 after a key-2 update, plus 310 after a key-1 write. expect(rebuiltHistories).toHaveLength(1767) expect( @@ -812,6 +821,7 @@ describe(`row metadata composition oracle`, () => { }) it(`enumerates nested histories for every nested write`, () => { + oracleRoute(`case`) const labels = new Set(nestedHistories.map(describeNested)) // 275 sequences for each of the seven nested variants. expect(nestedHistories).toHaveLength(1925) @@ -872,6 +882,7 @@ describe(`row metadata composition oracle`, () => { `matches last-write-wins metadata after a nested write (%s)`, { timeout: 120_000 }, async (path) => { + oracleRoute(`case`) const mismatches: Array = [] for (const [index, history] of nestedHistories.entries()) { const label = describeNested(history) @@ -898,6 +909,7 @@ describe(`row metadata composition oracle`, () => { ) it(`rejects named wrong answers in the held-window model`, () => { + oracleRoute(`calibration`) // Truncate keeps collection metadata, and an earlier set survives T2. expect( expectedHeldReads( @@ -923,6 +935,7 @@ describe(`row metadata composition oracle`, () => { `reads accepted metadata during a hold, then in an open transaction that is %s`, { timeout: 120_000 }, async (open) => { + oracleRoute(`case`) const mismatches: Array = [] for (const [index, { start, sequence }] of histories.entries()) { const label = `${start}: ${sequence.map(describeWrite).join(`, `)}` @@ -957,9 +970,10 @@ describe(`row metadata composition oracle`, () => { it.each([`immediate`, `held`, `rebuilt`] as const)( `matches last-write-wins metadata on the %s path`, { timeout: 120_000 }, - async (lane) => { + async (productionPath) => { + oracleRoute(`case`) const cases = - lane === `rebuilt` + productionPath === `rebuilt` ? rebuiltHistories : histories.map((history) => ({ ...history, earlier: undefined })) const mismatches: Array = [] @@ -968,10 +982,10 @@ describe(`row metadata composition oracle`, () => { let actual: unknown try { actual = await observeMetadata( - lane, + productionPath, start, sequence, - `row-metadata-composition-${lane}-${index}`, + `row-metadata-composition-${productionPath}-${index}`, earlier, ) } catch (error) { @@ -982,7 +996,7 @@ describe(`row metadata composition oracle`, () => { mismatches, label, actual, - expectedLaneMetadata(start, sequence, earlier), + expectedProductionPathMetadata(start, sequence, earlier), ) } expect(mismatches).toEqual([]) diff --git a/packages/db/tests/collection-state-retention-oracle.property.test.ts b/packages/db/tests/collection-state-retention-oracle.property.test.ts index 2d94e82a4f..93a237d721 100644 --- a/packages/db/tests/collection-state-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-state-retention-oracle.property.test.ts @@ -2,7 +2,13 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { DuplicateKeySyncError } from '../src/errors.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import { @@ -235,7 +241,13 @@ function expectRetainedState( ).toEqual(expectedRows) } -declareOracle({ check: [expectRetainedState] }) +// What this oracle owns: the gap report judges the retained Collection state, +// in state.ts and the sync writes that feed it in sync.ts, by this file's +// tests. +declareOracle({ + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + check: [expectRetainedState], +}) async function runRetentionHistory( actions: ReadonlyArray, @@ -416,6 +428,7 @@ async function runRetentionHistory( } it(`retains only keys in the authoritative synced state`, async () => { + oracleRoute(`fixed history`) await runRetentionHistory([ { type: `insert`, row: { id: 1, value: 1 } }, { type: `insert`, row: { id: 2, value: 2 } }, @@ -427,6 +440,7 @@ it(`retains only keys in the authoritative synced state`, async () => { }) it(`retains a missing row introduced by a sync update`, async () => { + oracleRoute(`fixed history`) await runRetentionHistory([{ type: `update`, row: { id: 1, value: 1 } }]) }) @@ -439,6 +453,7 @@ it.each( )( `retains an old-sync-run %s and a restarted row committed %s`, async (triggerType, commitPhase) => { + oracleRoute(`case`) await runRetentionHistory([ ...(triggerType === `update` ? ([{ type: `insert`, row: { id: 1, value: 1 } }] as const) @@ -453,6 +468,7 @@ it.each( ) it(`releases retained keys after long unique-key churn`, async () => { + oracleRoute(`fixed history`) const keyCount = 1_000 const actions: Array = [] for (let key = 0; key < keyCount; key++) { @@ -483,7 +499,7 @@ const retentionHistory = fc async function retentionCampaign( campaign: string, - options: Parameters[1], + options: NonNullable[1]>, ): Promise { retentionReach = createOracleReach( `collection-state.retention (${campaign})`, @@ -491,6 +507,7 @@ async function retentionCampaign( { minimum: 5 }, ) try { + oracleRoute(oracleRouteOf(options)) await fc.assert( fc.asyncProperty(retentionHistory, runRetentionHistory), options, @@ -501,15 +518,20 @@ async function retentionCampaign( } } -it(`matches retained authoritative state with a fixed seed`, () => - retentionCampaign(`fixed`, { numRuns: oracleRuns(100), seed: 1_902 })) -it(`matches retained authoritative state with a random or replayed seed`, () => - retentionCampaign( +it(`matches retained authoritative state with a fixed seed`, () => { + return retentionCampaign( + `fixed`, + oracleFixedSeed(1_902, { numRuns: oracleRuns(100) }), + ) +}) +it(`matches retained authoritative state with a random or replayed seed`, () => { + return retentionCampaign( `random`, oraclePropertyOptions(100, `collection-state.retention`), - )) + ) +}) -// The partial-update lane reuses those histories and marks source batches +// The partial-update production path reuses those histories and marks source batches // partial from a separate stream, so the full-mode campaigns keep their // seeded histories. It runs with the default `partial` row update mode. const markPartial = (step: OptimisticStep, partial: boolean): OptimisticStep => @@ -528,7 +550,7 @@ const partialHistory = fc steps: history.steps.map((step, index) => markPartial(step, marks[index]!)), })) -// These are replay programs for the same model and driver as randomized runs, +// These are fixed histories for the same model and driver as randomized runs, // not separate assertions that only know the reported final state. it.each( [false, true].flatMap((acceptBeforeTruncate) => @@ -543,6 +565,7 @@ it.each( )( `drops a direct deletion at settlement across replacement and later sync: %j`, async ({ acceptBeforeTruncate, replacementHasKey, reinsert }) => { + oracleRoute(`case`) const row = { id: 1, a: 1, b: 2, c: 3 } const steps: Array = [ { type: `delete`, key: 1, optimistic: true }, @@ -584,7 +607,7 @@ it.each( }, ) -// Replays of review probes. A sync transaction still open when the mutation +// Fixed histories from review probes. A sync transaction still open when the mutation // settles is not accepted, so it holds nothing and attributes nothing: after // it commits or aborts, a later remote write is remote. A completed key that // no queued sync touches stays remote while another key's sync is held. @@ -595,6 +618,7 @@ it.each( )( `attributes nothing to a sync transaction open at settlement: %j`, async ({ optimistic, commit }) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -623,6 +647,7 @@ it.each( }, ) it(`keeps an unrelated completed key remote while another key's sync is held`, async () => { + oracleRoute(`fixed history`) await runOptimisticHistory( [ { id: 1, a: 0, b: 0, c: 0 }, @@ -648,10 +673,11 @@ it(`keeps an unrelated completed key remote while another key's sync is held`, a ], ) }) -// Replays of random-campaign counterexamples. A rollback while a sync +// Fixed regressions of random-campaign counterexamples. A rollback while a sync // transaction on its key is still open must publish the drop. When two // completed transactions are held on one key, the newer one's row shows. it(`publishes a rollback while a sync transaction on its key is open`, async () => { + oracleRoute(`fixed history`) await runOptimisticHistory( [], [ @@ -683,6 +709,7 @@ it(`publishes a rollback while a sync transaction on its key is open`, async () ) }) it(`shows the newer of two held completed rows on one key`, async () => { + oracleRoute(`fixed history`) await runOptimisticHistory( [], [ @@ -726,9 +753,10 @@ it(`shows the newer of two held completed rows on one key`, async () => { ) }) -// Replay of a random-campaign counterexample. A confirmed delete must clear the +// Fixed regression of a random-campaign counterexample. A confirmed delete must clear the // key's local attribution, so a later source reinsert is remote. it(`attributes a source reinsert after a confirmed delete to the source`, async () => { + oracleRoute(`fixed history`) const row = { id: 2, a: 0, b: 0, c: 0 } const counts = await runOptimisticHistory( [], @@ -769,7 +797,11 @@ it(`attributes a source reinsert after a confirmed delete to the source`, async }) it(`writes source inserts and deletes in the fixed campaign`, async () => { - const histories = fc.sample(optimisticHistory, { seed: 86103, numRuns: 40 }) + oracleRoute(`fixed seed`) + const histories = fc.sample( + optimisticHistory, + oracleFixedSeed(86103, { numRuns: 40 }), + ) let inserts = 0 let deletes = 0 let absentDeletes = 0 @@ -785,7 +817,11 @@ it(`writes source inserts and deletes in the fixed campaign`, async () => { }) it(`writes distinguishing partial updates in the fixed partial campaign`, async () => { - const histories = fc.sample(partialHistory, { seed: 86104, numRuns: 40 }) + oracleRoute(`fixed seed`) + const histories = fc.sample( + partialHistory, + oracleFixedSeed(86104, { numRuns: 40 }), + ) let distinguishing = 0 for (const { initial, steps } of histories) { const counts = await runOptimisticHistory(initial, steps, undefined, { @@ -800,7 +836,11 @@ it(`writes distinguishing partial updates in the fixed partial campaign`, async // visible row only by a zero's sign. Neither the pending-writes flag nor // deep equality without signed zeros sees that change. it(`writes zero-sign edits over in-flight writes in the fixed campaign`, async () => { - const histories = fc.sample(optimisticHistory, { seed: 86103, numRuns: 40 }) + oracleRoute(`fixed seed`) + const histories = fc.sample( + optimisticHistory, + oracleFixedSeed(86103, { numRuns: 40 }), + ) let zeroSignEdits = 0 for (const { initial, steps } of histories) { const counts = await runOptimisticHistory(initial, steps) @@ -809,12 +849,12 @@ it(`writes zero-sign edits over in-flight writes in the fixed campaign`, async ( expect(zeroSignEdits).toBeGreaterThan(0) }) -// Pinned replays for a source delete of a key the source never held. The +// Fixed histories for a source delete of a key the source never held. The // backend accepted an optimistic insert, then deleted the row before the // source streamed it. The model's drain applies the delete to the applied // synced rows, where it changes nothing, so the visible result follows from // the settlement-drop law alone. The driver compares every step with the -// model; each count below proves the replay reached its premise. +// model; each count below proves the fixed history reached its premise. function absentKeyDelete(truncate = false) { return { type: `sync` as const, @@ -831,6 +871,7 @@ function absentKeyDelete(truncate = false) { it.each([`while persisting`, `inside the handler`] as const)( `removes a settled insert in its drop publication when the source deletes a key it never held %s`, async (delivery) => { + oracleRoute(`case`) const inHandler = delivery === `inside the handler` const counts = await runOptimisticHistory( [{ id: 2, a: 0, b: 0, c: 0 }], @@ -855,6 +896,7 @@ it.each([`while persisting`, `inside the handler`] as const)( // Once the optimistic state has dropped, the key is absent from the visible // rows, and the delete changes nothing a reader can see. it(`leaves the visible rows unchanged when the source deletes a never-held key after the drop`, async () => { + oracleRoute(`fixed history`) const counts = await runOptimisticHistory( [{ id: 2, a: 0, b: 0, c: 0 }], [ @@ -873,6 +915,7 @@ it(`leaves the visible rows unchanged when the source deletes a never-held key a it.each([false, true])( `keeps a persisting insert's optimistic row when the source deletes a key it never held, truncate=%s`, async (truncate) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [{ id: 2, a: 0, b: 0, c: 0 }], [ @@ -896,6 +939,7 @@ it.each( )( `publishes a request confirmed inside its own handler at settlement: %j`, async ({ kind, awaitReceipt }) => { + oracleRoute(`case`) const existing = { id: 1, a: 0, b: 0, c: 0 } const confirmed = { id: 1, a: 1, b: 1, c: 1 } const batch = { @@ -948,6 +992,7 @@ it.each( )( `keeps validated defaults with authored fields until settlement: %j`, async ({ truncate, success }) => { + oracleRoute(`case`) for (const insertDefault of [3, 11]) await runOptimisticHistory( [], @@ -981,6 +1026,7 @@ const partialOverPersistingDefault = ( it.each([3, 11])( `merges a held partial update into the source row under a persisting schema default (%i)`, async (insertDefault) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 7 }], partialOverPersistingDefault(insertDefault), @@ -992,6 +1038,7 @@ it.each([3, 11])( }, ) it(`rejects a partial update applied as a full replacement`, async () => { + oracleRoute(`calibration`) const steps = partialOverPersistingDefault(3) await runOptimisticHistory([{ id: 1, a: 0, b: 0, c: 7 }], steps, undefined, { insertDefault: 3, @@ -1010,6 +1057,7 @@ it(`rejects a partial update applied as a full replacement`, async () => { ) }) it(`rejects a default lost only after settlement`, async () => { + oracleRoute(`calibration`) const steps = defaultHistory(true, true) await runOptimisticHistory([], steps, undefined, { insertDefault: 3 }) await expect( @@ -1034,6 +1082,11 @@ it.each( )( `drops a completed row at settlement around truncate $timing (seed $seed)`, async ({ timing, seed }) => { + const parameters = + seed === undefined + ? oraclePropertyOptions(30, acceptedSnapshotReplayProperty[timing]) + : oracleFixedSeed(seed, { numRuns: oracleRuns(30) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(historyRow, fc.boolean(), async (row, reject) => { const truncate: OptimisticStep = { @@ -1085,9 +1138,7 @@ it.each( expect(counts.deletes).toBe(deletes) expect(counts.settlements).toBe(2 + deletes) }), - seed === undefined - ? oraclePropertyOptions(30, acceptedSnapshotReplayProperty[timing]) - : { seed, numRuns: oracleRuns(30) }, + parameters, ) }, ) @@ -1095,6 +1146,7 @@ it.each( it.each([true, false])( `replays an insert and its update across settlement, accepted=%s`, async (success) => { + oracleRoute(`case`) await runOptimisticHistory( [], [ @@ -1107,6 +1159,7 @@ it.each([true, false])( it.each([true, false])( `hides a sync row behind an optimistic row until settlement, truncate=%s`, async (truncate) => { + oracleRoute(`case`) await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -1123,6 +1176,7 @@ it.each([true, false])( }, ) it(`publishes prior optimistic ownership when rollback reveals an identical authoritative row`, async () => { + oracleRoute(`fixed history`) await runOptimisticHistory( [{ id: 3, a: 0, b: 0, c: 0 }], [ @@ -1135,6 +1189,7 @@ it(`publishes prior optimistic ownership when rollback reveals an identical auth ) }) it(`publishes the subscriber-visible row when a buffered optimistic update becomes a delete`, async () => { + oracleRoute(`fixed history`) const counts = await runOptimisticHistory( [], [ @@ -1167,6 +1222,7 @@ it.each( )( `drops each settled delete and edit across settlement orders: %j`, async ({ truncate, editSettlesFirst, insertAccepted }) => { + oracleRoute(`case`) const replacement: OptimisticStep = { type: `sync`, rows: [], @@ -1202,18 +1258,24 @@ it.each( }) }, ) -fcTest.prop([optimisticHistory], { numRuns: oracleRuns(100), seed: 86103 })( +const optimisticHistoryFixedSeed = oracleFixedSeed(86103, { + numRuns: oracleRuns(100), +}) +fcTest.prop([optimisticHistory], optimisticHistoryFixedSeed)( `matches optimistic ownership and publication histories with a fixed seed`, async ({ initial, steps }) => { + oracleRoute(oracleRouteOf(optimisticHistoryFixedSeed)) await runOptimisticHistory(initial, steps) }, ) -fcTest.prop( - [optimisticHistory], - oraclePropertyOptions(100, `collection-state.optimistic-history`), -)( +const optimisticHistoryCampaign = oraclePropertyOptions( + 100, + `collection-state.optimistic-history`, +) +fcTest.prop([optimisticHistory], optimisticHistoryCampaign)( `matches optimistic ownership and publication histories with a random or replayed seed`, async ({ initial, steps }) => { + oracleRoute(oracleRouteOf(optimisticHistoryCampaign)) await runOptimisticHistory(initial, steps) }, ) @@ -1221,6 +1283,7 @@ fcTest.prop( // to their state before it. A later partial update then omits `c` again, and // the Collection keeps the held `c`. Shrunk from seed -526998278. it(`merges a partial update after an aborted source delete`, async () => { + oracleRoute(`fixed history`) const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -1253,20 +1316,26 @@ it(`merges a partial update after an aborted source delete`, async () => { distinguishingPartialUpdates: 1, }) }) -fcTest.prop([partialHistory], { numRuns: oracleRuns(100), seed: 86104 })( +const partialHistoryFixedSeed = oracleFixedSeed(86104, { + numRuns: oracleRuns(100), +}) +fcTest.prop([partialHistory], partialHistoryFixedSeed)( `matches partial-update histories with a fixed seed`, async ({ initial, steps }) => { + oracleRoute(oracleRouteOf(partialHistoryFixedSeed)) await runOptimisticHistory(initial, steps, undefined, { partialUpdates: true, }) }, ) -fcTest.prop( - [partialHistory], - oraclePropertyOptions(100, `collection-state.optimistic-history-partial`), -)( +const partialHistoryCampaign = oraclePropertyOptions( + 100, + `collection-state.optimistic-history-partial`, +) +fcTest.prop([partialHistory], partialHistoryCampaign)( `matches partial-update histories with a random or replayed seed`, async ({ initial, steps }) => { + oracleRoute(oracleRouteOf(partialHistoryCampaign)) await runOptimisticHistory(initial, steps, undefined, { partialUpdates: true, }) diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts index 9fe42773fa..98d43ce7b6 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts @@ -30,7 +30,7 @@ import { declareOracle } from './oracle-declaration.js' * would hide the cross-boundary histories this oracle exists to test. Row * contents remain outside this grammar and have their own publication model. * - * Pinned histories name important paths. Generated histories explore adjacent + * Fixed histories name important paths. Generated histories explore adjacent * legal commands with fixed and random seeds. The drivers compare production * loads, unloads, results, status, errors, and publication against this trace. * 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 4416d7881b..62a1225213 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 @@ -26,12 +26,16 @@ import { import { flushPromises, withOracleCleanup } from './utils.js' import { createOracleReach } from './oracle-reach.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleRandomParameters, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from './oracle-config.js' +import { declareOracle } from './oracle-declaration.js' import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' import type { DemandName, @@ -48,6 +52,14 @@ const it = oracleReplayTest(vitestIt) const fixedCampaign = oracleReplayTest(fcTest) const randomCampaign = (property: string) => oracleReplayTest(fcTest, property) +// What this oracle owns: the gap report judges the subscription's logical +// demand and physical acquisition bookkeeping in subscription.ts, as the +// histories below drive it, by this file's tests. The lifecycle model lives in +// the grammar companion, which declares it. +declareOracle({ + owns: [`src/collection/subscription.ts`], +}) + /** * # What settles a demand created while a Collection is cleaned up? * @@ -72,6 +84,7 @@ describe(`demand created while its Collection is cleaned up`, () => { it.each(cleanedRestartDemandCases)( `$syncMode source with $loader loader, $replacement replacement, and $interruption`, async (history) => { + oracleRoute(`case`) const expected = expectedCleanedRestartDemand(history) let syncRuns = 0 let physicalAcquisitions = 0 @@ -219,6 +232,7 @@ describe(`demand created while its Collection is cleaned up`, () => { */ describe(`demand parked behind a failed first load across a reload`, () => { it.each(parkedReloadDemandCases)(`new run: $newRun`, async (history) => { + oracleRoute(`case`) const expected = expectedParkedReloadDemand(history) let syncRuns = 0 let loads = 0 @@ -1061,6 +1075,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { it.each([`resolve`, `reject`] as const)( `retains an earlier peer failure after aborting it while another peer will %s`, async (outcome) => { + oracleRoute(`case`) await runHistory( [ { type: `cleanup` }, @@ -1077,6 +1092,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ) it(`shares one normalized replay rejection among surviving detached callers`, async () => { + oracleRoute(`fixed history`) await runHistory( [ { type: `cleanup` }, @@ -1102,6 +1118,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ] as const)( `holds detached callers for their peer until %s`, async (finish) => { + oracleRoute(`case`) const history: Array = [ { type: `cleanup` }, { type: `request`, demand: `a` }, @@ -1131,6 +1148,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ) it(`does not revive a discarded caller when its logical demand restarts`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `cleanup` }, { type: `request`, demand: `a` }, @@ -1151,6 +1169,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ), ), )(`checks corrupted caller outcomes: %j`, async ({ origin, fault }) => { + oracleRoute(`calibration`) const history: Array = [ ...(origin === `detached` ? [{ type: `cleanup` } as const] : []), { type: `request`, demand: `a` }, @@ -1168,6 +1187,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { }) it(`retains the caller mismatch and a separate cleanup failure`, async () => { + oracleRoute(`calibration`) const cleanupFailure = new Error(`cleanup sentinel`) let reported: unknown try { @@ -1195,6 +1215,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { for (const outcome of [`resolve`, `reject`] as const) { it(`keeps terminal observations unchanged when a retired acquisition attempt ${outcome}s`, async () => { + oracleRoute(`fixed history`) const reach = await runHistory( [{ type: `request`, demand: `a` }, { type: `unsubscribe` }], { retiredDelivery: outcome }, @@ -1205,6 +1226,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { for (const firstOutcome of [`resolve`, `reject`] as const) { it(`settles only the corresponding returned result when the first acquisition ${firstOutcome}s`, async () => { + oracleRoute(`fixed history`) const secondOutcome = firstOutcome === `resolve` ? `reject` : `resolve` const history: Array = [ { type: `request`, demand: `a` }, @@ -1258,6 +1280,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { } it(`covers every required command and cross-phase transition`, async () => { + oracleRoute(`case`) const reach = new Set() for (const history of [ ...greenLifecycleHistories, @@ -1308,6 +1331,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { }) it(`names the overlapping replay transition in the model`, () => { + oracleRoute(`case`) const model = createLifecycleModel() for (const command of pendingSupersessionHistory) { reduceLifecycle(model, command) @@ -1337,11 +1361,13 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ])( `does not wait for an aborted load to settle for $name`, async ({ history }) => { + oracleRoute(`case`) await runHistory(history) }, ) it(`releases exact ownership while older replay work is pending`, async () => { + oracleRoute(`fixed history`) await runHistory( [ ...pendingSupersessionHistory, @@ -1367,6 +1393,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { )( `tracks $cancellation cancellation across $replays replay(s) ending in $outcome`, async ({ cancellation, replays, outcome }) => { + oracleRoute(`case`) await runHistory( [ { type: `request`, demand: `a` }, @@ -1403,11 +1430,13 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ])( `queues replay without reacquiring an aborted demand on $name`, async ({ history }) => { + oracleRoute(`case`) await runHistory(history) }, ) it(`does not release an unacquired replacement after an aborted demand replays`, async () => { + oracleRoute(`fixed history`) await runHistory( [ ...abortReplayHistory, @@ -1420,6 +1449,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { }) it(`replays a live peer without reacquiring an aborted demand`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `request`, demand: `a` }, { type: `request`, demand: `b` }, @@ -1445,6 +1475,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { }) it(`restarts a live peer without reacquiring an aborted demand and completes teardown`, async () => { + oracleRoute(`fixed history`) await runHistory(mixedAbortedRestartHistory, { continueAfterMismatch: true, }) @@ -1472,11 +1503,13 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { it.each(syncReplayScenarios)( `settles queued synchronous $transition with $ownerCount owner(s), abort=$abortFirst`, async ({ history }) => { + oracleRoute(`case`) await runHistory(history, { acquisitionMode: `sync-success` }) }, ) it(`preserves acquisition lease ownership across queued synchronous replay`, async () => { + oracleRoute(`fixed history`) await runHistory(syncLifecycleHistory, { acquisitionMode: `sync-success`, continueAfterMismatch: true, @@ -1537,6 +1570,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { }>)( `continues through the full synchronous replay suffix for $name`, async ({ history }) => { + oracleRoute(`case`) await runHistory(history, { acquisitionMode: `sync-success`, continueAfterMismatch: true, @@ -1545,6 +1579,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ) it(`publishes a new snapshot while canceled initial work still holds readiness`, async () => { + oracleRoute(`fixed history`) // Seed 1413322355, path 757:13:15:15:9:9:9. This checks an empty snapshot // notification: initial readiness is not a replacement publication gate. await runHistory( @@ -1582,6 +1617,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { }) it(`keeps a new snapshot private after failed restart`, async () => { + oracleRoute(`fixed history`) // Minimized from seed 317005625 at 100×. The mismatch is an empty // notification, not lost rows: failed replacement must keep reads private. await runHistory( @@ -1643,6 +1679,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { } it(`judges a replay once after a change that retires a replacement and a pending owner`, async () => { + oracleRoute(`fixed history`) const reached = await runHistory(changeRetiresReplacementHistory) expect(reached).toContain(`change:between-releases-would-publish`) }) @@ -1650,6 +1687,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { // Prompt cancellation of the replaced owner's replay load is a settlement: // the held replacement covers it, so the replay publishes. it(`publishes a replay when a covered owner's replay load is canceled`, async () => { + oracleRoute(`fixed history`) await runHistory( [ { type: `request`, demand: `c` }, @@ -1704,6 +1742,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ] satisfies Array<{ name: string; history: Array }>)( `keeps a replay failed when $name`, async ({ history }) => { + oracleRoute(`case`) await runDemandChangeHistory(history) }, ) @@ -1714,74 +1753,96 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { `reject` as const, ) - fixedCampaign.prop([greenLifecycleHistoryArbitrary, cancellationArbitrary], { + const greenLifecycleHistoryFixedSeed = oracleFixedSeed(1_657_003, { numRuns: runs, - seed: 1_657_003, - })( + }) + fixedCampaign.prop( + [greenLifecycleHistoryArbitrary, cancellationArbitrary], + greenLifecycleHistoryFixedSeed, + )( `matches the pure lifecycle model for a fixed seed`, async (history, cancellation) => { + oracleRoute(oracleRouteOf(greenLifecycleHistoryFixedSeed)) await runHistory(history, { cancellation }) }, 120_000, ) + const greenLifecycleHistoryCampaign = oracleRandomParameters( + runs, + replay, + `subscription-lifecycle.async-history`, + ) randomCampaign(`subscription-lifecycle.async-history`).prop( [greenLifecycleHistoryArbitrary, cancellationArbitrary], - oracleRandomParameters( - runs, - replay, - `subscription-lifecycle.async-history`, - ), + greenLifecycleHistoryCampaign, )( `matches the pure lifecycle model for a random or replayed seed`, async (history, cancellation) => { + oracleRoute(oracleRouteOf(greenLifecycleHistoryCampaign)) await runHistory(history, { cancellation }) }, 120_000, ) + const demandChangeLifecycleHistoryFixedSeed = oracleFixedSeed(1_657_005, { + numRuns: 5 * runs, + }) fixedCampaign.prop( [demandChangeLifecycleHistoryArbitrary, cancellationArbitrary], - { numRuns: 5 * runs, seed: 1_657_005 }, + demandChangeLifecycleHistoryFixedSeed, )( `judges a replay once per demand change for a fixed seed`, async (history, cancellation) => { + oracleRoute(oracleRouteOf(demandChangeLifecycleHistoryFixedSeed)) demandChangeHistoriesRan = true await runDemandChangeHistory(history, cancellation) }, // Five times the base budget; a run multiplier scales its histories. 120_000 * multiplier, ) + const demandChangeLifecycleHistoryCampaign = oracleRandomParameters( + 5 * runs, + replay, + `subscription-lifecycle.demand-change`, + ) randomCampaign(`subscription-lifecycle.demand-change`).prop( [demandChangeLifecycleHistoryArbitrary, cancellationArbitrary], - oracleRandomParameters( - 5 * runs, - replay, - `subscription-lifecycle.demand-change`, - ), + demandChangeLifecycleHistoryCampaign, )( `judges a replay once per demand change for a random or replayed seed`, async (history, cancellation) => { + oracleRoute(oracleRouteOf(demandChangeLifecycleHistoryCampaign)) demandChangeHistoriesRan = true await runDemandChangeHistory(history, cancellation) }, // Five times the base budget; a run multiplier scales its histories. 120_000 * multiplier, ) - fixedCampaign.prop([syncLifecycleHistoryArbitrary], { + const syncLifecycleHistoryFixedSeed = oracleFixedSeed(1_657_004, { numRuns: runs, - seed: 1_657_004, - })( + }) + fixedCampaign.prop( + [syncLifecycleHistoryArbitrary], + syncLifecycleHistoryFixedSeed, + )( `matches synchronous success histories for a fixed seed`, async (history) => { + oracleRoute(oracleRouteOf(syncLifecycleHistoryFixedSeed)) await runHistory(history, { acquisitionMode: `sync-success` }) }, 120_000, ) + const syncLifecycleHistoryCampaign = oracleRandomParameters( + runs, + replay, + `subscription-lifecycle.sync-history`, + ) randomCampaign(`subscription-lifecycle.sync-history`).prop( [syncLifecycleHistoryArbitrary], - oracleRandomParameters(runs, replay, `subscription-lifecycle.sync-history`), + syncLifecycleHistoryCampaign, )( `matches synchronous success histories for a random or replayed seed`, async (history) => { + oracleRoute(oracleRouteOf(syncLifecycleHistoryCampaign)) await runHistory(history, { acquisitionMode: `sync-success` }) }, 120_000, diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 732f772612..8542cc374f 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -11,9 +11,12 @@ import { flushPromises, } from './utils.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleRandomParameters, oracleReplayTest, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -753,7 +756,11 @@ function expectedThreeGenerationSettlement( } } +// What this oracle owns: the gap report judges the demand lifecycle of a +// subscription in subscription.ts, its startup, cleanup, restart, and +// release, by this file's tests. declareOracle({ + owns: [`src/collection/subscription.ts`], check: [runAsyncRestartScenario], model: [ modelSubscriberResumeRetirement, @@ -785,6 +792,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const { multiplier, ...replay } = readOracleRunConfig() it(`executes every required async restart regime`, async () => { + oracleRoute(`case`) const reach = new Set() for (const scenario of asyncRestartCoverageScenarios) { for (const label of await runAsyncRestartScenario(scenario)) { @@ -810,6 +818,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }) it(`covers every finite start, failure-delivery, and release cell`, () => { + oracleRoute(`case`) expect( new Set( startScenarios.map(({ outcome, reentry }) => `${outcome}:${reentry}`), @@ -833,6 +842,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }) it(`accounts for every acquisition phase and entry pair`, () => { + oracleRoute(`case`) const allCells = new Set( acquisitionPhases.flatMap((phase) => acquisitionEntries.map((entry) => `${phase}:${entry}` as const), @@ -844,6 +854,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }) it(`accounts for every acquisition lifecycle state and interaction cause`, () => { + oracleRoute(`case`) const allCells = new Set( acquisitionLifecycleStates.flatMap((state) => acquisitionInteractionCauses.map( @@ -865,6 +876,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { )( `retires startup at $entry with nested restart=$restart`, async ({ entry, restart }) => { + oracleRoute(`case`) const failure = new Error(`obsolete startup failed`) const cleanups: Array = [] const loads: Array = [] @@ -1001,6 +1013,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { )( `observes unavailable demand synchronously: $entry / $unavailable / $outcome / $phase`, async ({ outcome, phase, entry }) => { + oracleRoute(`case`) const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const transport = createDeferred() const failure = new Error(`recovery acquisition failed`) @@ -1162,6 +1175,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { )( `cancels queued acquisition during $phase via $action`, async ({ phase, action }) => { + oracleRoute(`case`) const loads: Array = [] const unloads: Array = [] const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) @@ -1225,6 +1239,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { )( `retires only the acquired lease for aborted replay with unload=$outcome, releaseSelf=$releaseSelf`, async ({ outcome, releaseSelf }) => { + oracleRoute(`case`) const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const controller = new AbortController() const failure = new Error(`release failed`) @@ -1308,6 +1323,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { )( `attempts release once across $reentry reentry with $failures configured failures`, async ({ reentry, failures }) => { + oracleRoute(`case`) const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const failure = new Error(`physical release failed`) const loads: Array = [] @@ -1390,6 +1406,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { it.each(threeGenerationScenarios)( `fences three sync-run generations for $obsoleteOutcome/$currentOutcome settled $settlementOrder`, async ({ obsoleteOutcome, currentOutcome, settlementOrder }) => { + oracleRoute(`case`) type Row = { id: string; version: number } const obsolete = createDeferred() const current = createDeferred() @@ -1511,27 +1528,30 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const fixedCampaign = oracleReplayTest(fcTest) - fixedCampaign.prop([asyncRestartScenarioArbitrary], { + const asyncRestartScenarioFixedSeed = oracleFixedSeed(1_657_002, { numRuns: 30 * multiplier, - seed: 1_657_002, - })( + }) + fixedCampaign.prop( + [asyncRestartScenarioArbitrary], + asyncRestartScenarioFixedSeed, + )( `fences async demand settlements across sync-run generations for a fixed seed`, async (scenario) => { + oracleRoute(oracleRouteOf(asyncRestartScenarioFixedSeed)) await runAsyncRestartScenario(scenario) }, 120_000, ) - fcTest.prop( - [asyncRestartScenarioArbitrary], - oracleRandomParameters( - 30 * multiplier, - replay, - `subscription-lifecycle.async-restart`, - ), - )( + const asyncRestartScenarioCampaign = oracleRandomParameters( + 30 * multiplier, + replay, + `subscription-lifecycle.async-restart`, + ) + fcTest.prop([asyncRestartScenarioArbitrary], asyncRestartScenarioCampaign)( `fences async demand settlements across sync-run generations for a random or replayed seed`, async (scenario) => { + oracleRoute(oracleRouteOf(asyncRestartScenarioCampaign)) await runAsyncRestartScenario(scenario) }, 120_000, @@ -1562,6 +1582,14 @@ describe(`resumption notification error and surviving demand`, () => { `held-cleanup`, `replacement-ready`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { ...oraclePropertyOptions(60) }) + : oraclePropertyOptions( + 60, + `subscription-lifecycle.notification-error`, + ) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -1650,12 +1678,7 @@ describe(`resumption notification error and surviving demand`, () => { } }, ), - campaign === `fixed` - ? { ...oraclePropertyOptions(60), seed: 2229 } - : oraclePropertyOptions( - 60, - `subscription-lifecycle.notification-error`, - ), + parameters, ) reach.assertReached() console.info( @@ -1682,6 +1705,7 @@ describe(`resumption notification error and surviving demand`, () => { )( `ordinary admission preserves $cleanupAt cleanup (pending=$cleanupPending, $gate start, $syncMode)`, async (history) => { + oracleRoute(`case`) const expected = modelSubscriberStartCleanup(history) const pendingCleanup = createDeferred() let starts = 0 @@ -1781,6 +1805,7 @@ describe(`resumption notification error and surviving demand`, () => { )( `balances resume at $retireAt (cleanup=$cleanup, $syncMode, gate=$renderGate)`, async (history) => { + oracleRoute(`case`) const expected = modelSubscriberResumeRetirement(history) let starts = 0 let loads = 0 diff --git a/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts b/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts index 88b2f2ea24..72a20f4df8 100644 --- a/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts @@ -13,7 +13,13 @@ import { greenLifecycleHistoryArbitrary, reduceLifecycle, } from './collection-subscription-lifecycle-grammar-oracle.js' -import { oracleRandomParameters, readOracleRunConfig } from './oracle-config.js' +import { + oracleFixedSeed, + oracleRandomParameters, + oracleRoute, + oracleRouteOf, + readOracleRunConfig, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import { flushPromises } from './utils.js' @@ -1854,7 +1860,11 @@ function expectedSelection( // The publication check compares each command's batches, visible rows, status, // and unloads with the model. The model predicts the publication step by // step, and the selection model predicts which keys local snapshots select. +// What this oracle owns: the gap report judges which rows a subscription +// publishes to subscribers, in subscription.ts and in the Collection state that +// holds the rows, by this file's tests across replay, restart, and cleanup. declareOracle({ + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], check: [runPublicationHistory, runPublicationProduct, runRestartReceiver], model: [ observeRestartRow, @@ -1911,6 +1921,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { it.each(selectionCases)( `selects each local key once across $first then ordered, includeInitialState=$includeInitialState, reenter=$reenter`, async ({ includeInitialState, first, reenter }) => { + oracleRoute(`case`) // Local selections are modeled by expectedSelection; visible is the // independent callback consumer's row set. const { batches: expectedBatches, selected } = expectedSelection( @@ -2017,6 +2028,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { }, ) it(`reconstructs every bounded deferral cleanup history`, () => { + oracleRoute(`case`) expect(deferralCleanupHistories).toHaveLength(32) expect(new Set(deferralCleanupHistories.map(({ name }) => name)).size).toBe( 32, @@ -2025,6 +2037,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { it.each(deferralCleanupHistories)( `publishes independent batches after deferral cleanup: $name`, async ({ commands }) => { + oracleRoute(`case`) await runDeferralCleanupHistory(commands) }, ) @@ -2044,6 +2057,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { )( `observes retired transports without subscriber activity: %j`, async ({ phase, demandCount, outcome, order }) => { + oracleRoute(`case`) const history: Array = [ { type: `source`, key: `c`, action: `upsert`, value: 42 }, { type: `request`, demand: `a` }, @@ -2071,6 +2085,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { }, ) it(`keeps raw source updates after retiring the final replay owner`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `cleanup` }, { type: `release`, demand: `b` }, @@ -2087,6 +2102,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`keeps raw truncate deletes after retiring the final replay owner`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `settle`, @@ -2112,6 +2128,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`records requested snapshot rows before a canceled reset`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `request`, demand: `b` }, { type: `truncate` }, @@ -2129,6 +2146,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`does not invent a publication for an unchanged private row`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `request`, demand: `a` }, { type: `cleanup` }, @@ -2149,6 +2167,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { )( `distinguishes raw source resets from retained replacements: $baseline/$reset`, async ({ baseline, reset }) => { + oracleRoute(`case`) await runPublicationHistory([ ...(baseline === `retained` || baseline === `refreshed` ? [{ type: `source`, key: `d`, action: `upsert`, value: 0 } as const] @@ -2176,6 +2195,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { }, ) it(`reconciles a repeated reset after the last replay owner aborts`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 0 }, { type: `request`, demand: `a` }, @@ -2193,6 +2213,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`keeps independent source rows when a replay settles after redundant restart calls`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 0 }, { type: `request`, demand: `a` }, @@ -2227,6 +2248,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`defines all 288 unique row-publication lifecycle cells`, () => { + oracleRoute(`case`) expect(publicationProductCases).toHaveLength(288) expect(new Set(publicationProductCases.map(({ name }) => name)).size).toBe( 288, @@ -2239,30 +2261,36 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { it.each(publicationControlSlices)( `matches row publications for lifecycle control cells $name`, async ({ scenarios }) => { + oracleRoute(`case`) expectNoPublicationMismatches(await runPublicationProduct(scenarios)) }, ) it(`preserves independent source work when a successful replay publishes`, async () => { + oracleRoute(`fixed history`) expectNoPublicationMismatches( await runPublicationProduct(successfulReplacementCases), ) }) it(`publishes complete replacement batches regardless of independent key order`, async () => { + oracleRoute(`fixed history`) expectNoPublicationMismatches( await runPublicationProduct(replacementOrderingCases), ) }) it(`retires incomplete or failed replacement without changing independent public rows`, async () => { + oracleRoute(`fixed history`) expectNoPublicationMismatches( await runPublicationProduct(replacementRetirementCases), ) }) it(`maps every canonical green lifecycle history to public rows`, async () => { + oracleRoute(`case`) for (const history of greenLifecycleHistories) { await runPublicationHistory(history) } }) it(`suppresses canceled source writes when a released acquisition settles`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory( [ { type: `request`, demand: `a` }, @@ -2284,6 +2312,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) }) it(`publishes an authoritative truncate after the final demand is released`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `request`, demand: `b` }, { @@ -2299,6 +2328,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`publishes independent source changes with a successful replay`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory( [ { type: `request`, demand: `a` }, @@ -2327,6 +2357,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) }) it(`does not republish a row already delivered by a live change`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory( [ { type: `source`, key: `a`, action: `upsert`, value: 0 }, @@ -2343,6 +2374,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) }) it(`suppresses canceled source writes when an aborted acquisition settles`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory( [ { type: `request`, demand: `a` }, @@ -2363,6 +2395,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) }) it(`keeps later source changes private after a failed replay`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `request`, demand: `a` }, { @@ -2504,12 +2537,14 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ] as Array<[string, Array]>)( `leaves no stale row after a retired replay: %s`, async (_, history) => { + oracleRoute(`case`) await runPublicationHistory(history) }, ) // Law 9: a failed replay stays private until an authoritative replay // succeeds, even after its failed owner is released and nothing remains. it(`keeps a failed replay private when its final owner releases`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 1 }, { type: `request`, demand: `a` }, @@ -2533,6 +2568,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`retires failed private replacement rows when its final owner releases`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory( [ { type: `source`, key: `b`, action: `upsert`, value: 7 }, @@ -2566,6 +2602,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { // later source write of 0 must reconcile as an update, since -0 and 0 are // distinct row values (maintainer decision, 2026-10-29). it(`reconciles a retained -0 row with a later source write of 0`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory( [ { type: `source`, key: `b`, action: `upsert`, value: -0 }, @@ -2596,6 +2633,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) }) it(`does not delete an independent public row when releasing a restarted demand`, async () => { + oracleRoute(`fixed history`) await runPublicationHistory( [ { type: `source`, key: `b`, action: `upsert`, value: 0 }, @@ -2610,6 +2648,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) }) it(`keeps a restarted snapshot private while canceled replay work settles`, async () => { + oracleRoute(`fixed history`) // Seed 2018803696, path 65:10:2:10:13:12:12:0:0:0. A canceled-only // truncate does not discharge the earlier replay's publication wait. await runPublicationHistory( @@ -2643,6 +2682,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) }) it(`matches retained rows across an empty restart and canceled-only truncates`, async () => { + oracleRoute(`fixed history`) // Seed 2018803696, path 65:29:0:0:0 exposed the model's missing retained-row // deletion: an authoritative empty reset is not limited to resident rows. await runPublicationHistory( @@ -2686,6 +2726,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { )( `publishes an authoritative empty reset: %j`, async ({ restart, canceledOwner }) => { + oracleRoute(`case`) await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 0 }, ...(restart @@ -2726,6 +2767,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { )( `installs a retained-row replacement atomically: %j`, async ({ canceledOwner, replacement }) => { + oracleRoute(`case`) await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 0 }, { type: `cleanup` }, @@ -2751,6 +2793,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ] satisfies Array)( `publishes eager restart before a later source reset: %j`, async (replacement) => { + oracleRoute(`case`) await runPublicationHistory( [ { type: `source`, key: `a`, action: `upsert`, value: 0 }, @@ -2765,6 +2808,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { }, ) it(`resets retained rows after no-op cleanup and release commands`, async () => { + oracleRoute(`fixed history`) // Seed 1657005, path 164:18 after removing the visible-row request omission. await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 1 }, @@ -2788,6 +2832,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`resets retained rows after the last replay owner retires`, async () => { + oracleRoute(`fixed history`) // Seed 333468655, path 59:13:0:0:0. await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 0 }, @@ -2810,6 +2855,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`does not restore source rows when the final replay owner retires`, async () => { + oracleRoute(`fixed history`) // Seed 1337491191, path 591:20:1:8:8:8:7:7. await runPublicationHistory([ { type: `source`, key: `a`, action: `upsert`, value: 0 }, @@ -2825,6 +2871,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ]) }) it(`keeps an equal row restored by the restarted acquisition`, async () => { + oracleRoute(`fixed history`) // Original generated failure: seed -1439698414, path 1:22. await runPublicationHistory([ { type: `source`, key: `b`, action: `upsert`, value: 1 }, @@ -2859,10 +2906,10 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { const { multiplier, ...replay } = readOracleRunConfig() const restartProperty = `subscription-lifecycle.restart-publication` - // Each generated input runs every structural lane. Reach never depends on + // Each generated input runs every structural grammar. Reach never depends on // the random seed choosing equality, a live owner, or ordinary source delivery. // Both campaigns use the same budget, grammar, model, driver and comparisons. - for (const seed of replay.replaySeed === undefined + for (const seed of replay.campaignSeed === undefined ? [2310, undefined] : [undefined]) { for (const delivery of restartDeliveries) { @@ -2875,6 +2922,11 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { scenarios.map(({ state }) => state), { minimum: 12 * multiplier }, ) + const parameters = { + ...oracleRandomParameters(12 * multiplier, replay, restartProperty), + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(restartPublicationInputArbitrary, async (input) => { for (const scenario of scenarios) { @@ -2934,16 +2986,13 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { ) } }), - { - ...oracleRandomParameters(12 * multiplier, replay, restartProperty), - ...(seed === undefined ? {} : { seed }), - }, + parameters, ) reach.assertReached() }, 120_000) } } - for (const seed of replay.replaySeed === undefined + for (const seed of replay.campaignSeed === undefined ? [2310, undefined] : [undefined]) { for (const path of restartReceiverPaths) { @@ -2953,6 +3002,11 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { restartRowStates.map((row) => `${path}:${row}`), { minimum: 12 * multiplier }, ) + const campaignParameters = { + ...oracleRandomParameters(12 * multiplier, replay, restartProperty), + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(restartPublicationInputArbitrary, async (input) => { for (const row of restartRowStates) { @@ -2960,10 +3014,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { reach.reach(await runRestartReceiver(input, path, row)) } }), - { - ...oracleRandomParameters(12 * multiplier, replay, restartProperty), - ...(seed === undefined ? {} : { seed }), - }, + campaignParameters, ) reach.assertReached() }, 120_000) @@ -2974,15 +3025,16 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { // carried row): the random grammar inserts source writes into green // lifecycle histories. The pinned zero-sign history below covers that path. it(`reaches a zero-sign source write in the fixed campaign`, async () => { + oracleRoute(`fixed seed`) const reach = createOracleReach( `lifecycle-publication.zero-sign`, [`zero-sign-write`], { minimum: 5 }, ) - for (const history of fc.sample(publicationCommandHistoryArbitrary, { - seed: 1_657_005, - numRuns: runs, - })) { + for (const history of fc.sample( + publicationCommandHistoryArbitrary, + oracleFixedSeed(1_657_005, { numRuns: runs }), + )) { reach.beginHistory() const before = zeroSignWrites await runPublicationHistory(history) @@ -2990,26 +3042,32 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { } reach.assertReached() }, 120_000) - fcTest.prop([publicationCommandHistoryArbitrary], { + const publicationCommandHistoryFixedSeed = oracleFixedSeed(1_657_005, { numRuns: runs, - seed: 1_657_005, - })( + }) + fcTest.prop( + [publicationCommandHistoryArbitrary], + publicationCommandHistoryFixedSeed, + )( `matches row publications for a fixed seed`, async (history) => { + oracleRoute(oracleRouteOf(publicationCommandHistoryFixedSeed)) await runPublicationHistory(history) }, 120_000, ) + const publicationCommandHistoryCampaign = oracleRandomParameters( + runs, + replay, + `subscription-lifecycle.publication-history`, + ) fcTest.prop( [publicationCommandHistoryArbitrary], - oracleRandomParameters( - runs, - replay, - `subscription-lifecycle.publication-history`, - ), + publicationCommandHistoryCampaign, )( `matches row publications for a random or replayed seed`, async (history) => { + oracleRoute(oracleRouteOf(publicationCommandHistoryCampaign)) await runPublicationHistory(history) }, 120_000, diff --git a/packages/db/tests/collection-subscription-lifecycle.test.ts b/packages/db/tests/collection-subscription-lifecycle.test.ts index 2cc999f505..0b3244e0f2 100644 --- a/packages/db/tests/collection-subscription-lifecycle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle.test.ts @@ -11,7 +11,7 @@ import { startOutcomes } from './collection-subscription-lifecycle-cell-support. import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' /** - * Pinned unit tests for CollectionSubscription demand lifecycle: concrete + * Fixed unit tests for CollectionSubscription demand lifecycle: concrete * acquisition, release, restart, and replay examples whose expectations are * written by hand. The law, its model, and the generated scenario checks are * owned by collection-subscription-lifecycle-oracle.test.ts. diff --git a/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts b/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts index f82dcb4315..1f01026916 100644 --- a/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts @@ -4,6 +4,7 @@ import { createDeferred } from '../src/deferred.js' import { Func, PropRef, Value } from '../src/query/ir.js' import { where } from './collection-subscription-reentrancy-fixture.js' import { reentrantSnapshotHistories } from './collection-subscription-lifecycle-grammar-oracle.js' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import type { LoadSubsetOptions } from '../src/types.js' @@ -55,12 +56,19 @@ function expectSnapshotReentryOutcome(observed: { ) } -declareOracle({ check: [expectSnapshotReentryOutcome] }) +// What this oracle owns: the gap report judges the snapshot request and its +// release on a subscription, in subscription.ts, by this file's tests, when a +// callback reenters with a demand-retiring action. +declareOracle({ + owns: [`src/collection/subscription.ts`], + check: [expectSnapshotReentryOutcome], +}) describe(`Collection subscription callback reentry oracle`, () => { it.each(reentrantSnapshotHistories)( `stops a direct snapshot after $phase calls $action`, async ({ phase, action }) => { + oracleRoute(`case`) let reentries = 0 let armSnapshotReentry = false const threshold = { diff --git a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts index 91c59838c6..9622cdc384 100644 --- a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -7,7 +7,13 @@ import { Func, PropRef, Value } from '../src/query/ir.js' import { DeduplicatedLoadSubset } from '../src/query/subset-dedupe.js' import { createTransaction } from '../src/transactions.js' import { valueOrderBy } from './collection-subscription-ordered-fixture.js' -import { oracleRandomParameters, readOracleRunConfig } from './oracle-config.js' +import { + oracleFixedSeed, + oracleRandomParameters, + oracleRoute, + oracleRouteOf, + readOracleRunConfig, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { flushPromises } from './utils.js' import { @@ -1522,7 +1528,10 @@ function predictFilteredReplay( return { nextOrdered, replacement } } +// What this oracle owns: the gap report judges every function in +// collection/subscription.ts by this file's replay histories. declareOracle({ + owns: [`src/collection/subscription.ts`], check: [ runReplayScenario, runSequentialReplayScenario, @@ -1543,10 +1552,11 @@ declareOracle({ describe(`CollectionSubscription replay oracle`, () => { it(`generates shared, failed, stale, released, and post-replay histories`, () => { - const scenarios = fc.sample(replayScenarioArbitrary, { - seed: 1755, - numRuns: 300, - }) + oracleRoute(`fixed seed`) + const scenarios = fc.sample( + replayScenarioArbitrary, + oracleFixedSeed(1755, { numRuns: 300 }), + ) expect(scenarios.some(({ demandIds }) => demandIds.length > 1)).toBe(true) expect(scenarios.some(({ attempts }) => attempts.length > 1)).toBe(true) @@ -1590,6 +1600,7 @@ describe(`CollectionSubscription replay oracle`, () => { // is a legal reentry cut: it can read the source replacement before this // subscriber receives the same source transaction. it(`continues after a privately selected filtered snapshot during callback reentry`, async () => { + oracleRoute(`fixed history`) type Row = { id: string; value: number } const rows: Array = [ { id: `one`, value: 1 }, @@ -1732,6 +1743,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`keeps private row tracking through consecutive failed replays`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [ { id: `one`, value: -2 }, @@ -1777,6 +1789,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`retains a successful retry after retiring its failed peer`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [{ id: `two`, value: 0 }], demandIds: [`one`, `two`], @@ -1822,6 +1835,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`does not republish an identical snapshot after a synchronous replay failure`, async () => { + oracleRoute(`fixed history`) await runSequentialReplayScenario({ initialRows: [{ id: `one`, value: 0 }], loads: [ @@ -1832,6 +1846,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`keeps a same-key source replacement private after a failed replay`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`], @@ -1847,6 +1862,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`does not let an unpublished truncate delete suppress a later insert`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [], demandIds: [`two`, `one`], @@ -1879,6 +1895,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`lets the newest successful replay replace an older failed replay`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`], @@ -1903,6 +1920,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`ignores an obsolete replay that settles after the newest replay`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [{ id: `one`, value: 0 }], demandIds: [`one`], @@ -1933,6 +1951,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`releases every successful overlapping replay acquisition`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [], demandIds: [`one`], @@ -1951,6 +1970,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`uses the newest complete multi-demand replay`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`, `two`], @@ -1987,6 +2007,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`retains applied rows after their replay demand is released`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [{ id: `two`, value: 0 }], demandIds: [`one`, `two`], @@ -2015,6 +2036,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`refreshes a retained row when its final released demand is reacquired`, async () => { + oracleRoute(`fixed history`) // Reduced from the fixed replay corpus after removing release-time pruning. await runReplayScenario({ initialRows: [{ id: `one`, value: 0 }], @@ -2039,6 +2061,7 @@ describe(`CollectionSubscription replay oracle`, () => { }) it(`replaces a retained snapshot with a later empty replay`, async () => { + oracleRoute(`fixed history`) await runReplayScenario({ initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`], @@ -2056,131 +2079,181 @@ describe(`CollectionSubscription replay oracle`, () => { }) }) - fcTest.prop([replayScenarioArbitrary], { + const replayScenarioFixedSeed = oracleFixedSeed(1756, { numRuns: generatedRuns, - seed: 1756, - })(`matches replay and ownership laws for a fixed seed`, runReplayScenario) + }) + fcTest.prop([replayScenarioArbitrary], replayScenarioFixedSeed)( + `matches replay and ownership laws for a fixed seed`, + (...args) => { + oracleRoute(oracleRouteOf(replayScenarioFixedSeed)) + return runReplayScenario(...args) + }, + ) // The fixed campaign's histories reach a replacement that changes a row // only by a zero's sign, where a diff without signed zeros publishes // nothing. it(`reaches a zero-sign replacement in the fixed campaign`, async () => { + oracleRoute(`fixed seed`) zeroSignReplacements = 0 - for (const scenario of fc.sample(replayScenarioArbitrary, { - seed: 1756, - numRuns: generatedRuns, - })) { + for (const scenario of fc.sample( + replayScenarioArbitrary, + oracleFixedSeed(1756, { numRuns: generatedRuns }), + )) { await runReplayScenario(scenario) } expect(zeroSignReplacements).toBeGreaterThan(0) }) - fcTest.prop( - [replayScenarioArbitrary], - oracleRandomParameters( - generatedRuns, - replayConfig, - `subscription-replay.completion`, - ), - )( + const replayScenarioCampaign = oracleRandomParameters( + generatedRuns, + replayConfig, + `subscription-replay.completion`, + ) + fcTest.prop([replayScenarioArbitrary], replayScenarioCampaign)( `matches replay and ownership laws for a random or replayed seed`, - runReplayScenario, + (...args) => { + oracleRoute(oracleRouteOf(replayScenarioCampaign)) + return runReplayScenario(...args) + }, ) - fcTest.prop([sequentialReplayScenarioArbitrary], { + const sequentialReplayScenarioFixedSeed = oracleFixedSeed(1761, { numRuns: generatedRuns, - seed: 1761, - })( + }) + fcTest.prop( + [sequentialReplayScenarioArbitrary], + sequentialReplayScenarioFixedSeed, + )( `matches synchronous, asynchronous, and partial-failure replay laws for a fixed seed`, - runSequentialReplayScenario, + (...args) => { + oracleRoute(oracleRouteOf(sequentialReplayScenarioFixedSeed)) + return runSequentialReplayScenario(...args) + }, ) + const sequentialReplayScenarioCampaign = oracleRandomParameters( + generatedRuns, + replayConfig, + `subscription-replay.sequential`, + ) fcTest.prop( [sequentialReplayScenarioArbitrary], - oracleRandomParameters( - generatedRuns, - replayConfig, - `subscription-replay.sequential`, - ), + sequentialReplayScenarioCampaign, )( `matches synchronous, asynchronous, and partial-failure replay laws for a random or replayed seed`, - runSequentialReplayScenario, + (...args) => { + oracleRoute(oracleRouteOf(sequentialReplayScenarioCampaign)) + return runSequentialReplayScenario(...args) + }, ) - fcTest.prop([cleanupRestartScenarioArbitrary], { + const cleanupRestartScenarioFixedSeed = oracleFixedSeed(1757, { numRuns: generatedRuns, - seed: 1757, - })( - `isolates cleanup and restart sync runs for a fixed seed`, - runCleanupRestartScenario, - ) + }) + fcTest.prop( + [cleanupRestartScenarioArbitrary], + cleanupRestartScenarioFixedSeed, + )(`isolates cleanup and restart sync runs for a fixed seed`, (...args) => { + oracleRoute(oracleRouteOf(cleanupRestartScenarioFixedSeed)) + return runCleanupRestartScenario(...args) + }) + const cleanupRestartScenarioCampaign = oracleRandomParameters( + generatedRuns, + replayConfig, + `subscription-replay.restart`, + ) fcTest.prop( [cleanupRestartScenarioArbitrary], - oracleRandomParameters( - generatedRuns, - replayConfig, - `subscription-replay.restart`, - ), + cleanupRestartScenarioCampaign, )( `isolates cleanup and restart sync runs for a random or replayed seed`, - runCleanupRestartScenario, + (...args) => { + oracleRoute(oracleRouteOf(cleanupRestartScenarioCampaign)) + return runCleanupRestartScenario(...args) + }, ) - fcTest.prop([fc.scheduler()], { numRuns: generatedRuns, seed: 1760 })( + const fixedCampaign = oracleFixedSeed(1760, { numRuns: generatedRuns }) + fcTest.prop([fc.scheduler()], fixedCampaign)( `keeps same-tick obsolete and current replay settlements generation-safe`, - expectScheduledReplaySettlementIsGenerationSafe, + (...args) => { + oracleRoute(oracleRouteOf(fixedCampaign)) + return expectScheduledReplaySettlementIsGenerationSafe(...args) + }, ) - fcTest.prop( - [fc.scheduler()], - oracleRandomParameters( - generatedRuns, - replayConfig, - `subscription-replay.same-tick`, - ), - )( + const randomCampaign = oracleRandomParameters( + generatedRuns, + replayConfig, + `subscription-replay.same-tick`, + ) + fcTest.prop([fc.scheduler()], randomCampaign)( `keeps same-tick replay settlements generation-safe for a random or replayed seed`, - expectScheduledReplaySettlementIsGenerationSafe, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign)) + return expectScheduledReplaySettlementIsGenerationSafe(...args) + }, ) - fcTest.prop([sharedSubscriptionScenarioArbitrary], { + const sharedSubscriptionScenarioFixedSeed = oracleFixedSeed(1758, { numRuns: generatedRuns, - seed: 1758, - })( + }) + fcTest.prop( + [sharedSubscriptionScenarioArbitrary], + sharedSubscriptionScenarioFixedSeed, + )( `keeps independent transport and logical ownership aligned for a fixed seed`, - runSharedSubscriptionScenario, + (...args) => { + oracleRoute(oracleRouteOf(sharedSubscriptionScenarioFixedSeed)) + return runSharedSubscriptionScenario(...args) + }, ) + const sharedSubscriptionScenarioCampaign = oracleRandomParameters( + generatedRuns, + replayConfig, + `subscription-replay.shared`, + ) fcTest.prop( [sharedSubscriptionScenarioArbitrary], - oracleRandomParameters( - generatedRuns, - replayConfig, - `subscription-replay.shared`, - ), + sharedSubscriptionScenarioCampaign, )( `keeps independent transport and logical ownership aligned for a random or replayed seed`, - runSharedSubscriptionScenario, + (...args) => { + oracleRoute(oracleRouteOf(sharedSubscriptionScenarioCampaign)) + return runSharedSubscriptionScenario(...args) + }, ) - fcTest.prop([optimisticReplayScenarioArbitrary], { + const optimisticReplayScenarioFixedSeed = oracleFixedSeed(1759, { numRuns: generatedRuns, - seed: 1759, - })( + }) + fcTest.prop( + [optimisticReplayScenarioArbitrary], + optimisticReplayScenarioFixedSeed, + )( `preserves optimistic overlays across replay outcomes for a fixed seed`, - runOptimisticReplayScenario, + (...args) => { + oracleRoute(oracleRouteOf(optimisticReplayScenarioFixedSeed)) + return runOptimisticReplayScenario(...args) + }, ) + const optimisticReplayScenarioCampaign = oracleRandomParameters( + generatedRuns, + replayConfig, + `subscription-replay.optimistic`, + ) fcTest.prop( [optimisticReplayScenarioArbitrary], - oracleRandomParameters( - generatedRuns, - replayConfig, - `subscription-replay.optimistic`, - ), + optimisticReplayScenarioCampaign, )( `preserves optimistic overlays across replay outcomes for a random or replayed seed`, - runOptimisticReplayScenario, + (...args) => { + oracleRoute(oracleRouteOf(optimisticReplayScenarioCampaign)) + return runOptimisticReplayScenario(...args) + }, ) }) diff --git a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts index 3960a5f7eb..5104c32cf8 100644 --- a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts @@ -24,7 +24,7 @@ * outcomes. It compares them after the outer commit returns and after the * receipts settle. * - * Limits: the pinned tests cover layout marks, truncates, listener errors, and + * Limits: the fixed examples cover layout marks, truncates, listener errors, and * subset release. The generated grammar covers only listener commits, aborts, * and one open transaction. Optimistic settlement belongs to * `optimistic-history-oracle.ts`. @@ -34,7 +34,13 @@ import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection, eq } from '../src/query/index.js' import { createDeferred } from '../src/deferred.js' -import { oracleRandomParameters, readOracleRunConfig } from './oracle-config.js' +import { + oracleFixedSeed, + oracleRandomParameters, + oracleRoute, + oracleRouteOf, + readOracleRunConfig, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { flushPromises } from './utils.js' import { @@ -192,7 +198,10 @@ async function runListenerScenario(scenario: ListenerScenario): Promise { } } +// What this oracle owns: the gap report judges every function in +// collection/sync.ts by this file's publication histories. declareOracle({ + owns: [`src/collection/sync.ts`], check: [runListenerScenario], grammar: [listenerScenarioArbitrary, exhaustiveListenerScenarios], }) @@ -282,6 +291,7 @@ describe(`sync publication reentrancy`, () => { }) it(`enumerates prior requests and legal ready-callback writes`, () => { + oracleRoute(`case`) // none and update 1 allow 6 writes, delete 1 allows 5, insert 3 allows 7. expect(readyCases).toHaveLength(2 * 24 * 3) const labels = new Set( @@ -306,6 +316,7 @@ describe(`sync publication reentrancy`, () => { it.each(readyCases)( `publishes valid truncate messages when a $hook callback runs $callbackName after prior $priorName ($subscriber subscriber)`, async ({ hook, prior, callback, subscriber }) => { + oracleRoute(`case`) const persistence = createDeferred() let sync!: SyncOps const collection = createCollection({ @@ -459,25 +470,33 @@ describe(`sync publication reentrancy`, () => { ) it(`matches every bounded reentrant listener history`, async () => { + oracleRoute(`case`) for (const scenario of exhaustiveListenerScenarios) { await runListenerScenario(scenario) } }) - fcTest.prop([listenerScenarioArbitrary], { + const listenerScenarioFixedSeed = oracleFixedSeed(1774, { numRuns: generatedRuns, - seed: 1774, - })(`matches the reentrant drain laws for a fixed seed`, runListenerScenario) + }) + fcTest.prop([listenerScenarioArbitrary], listenerScenarioFixedSeed)( + `matches the reentrant drain laws for a fixed seed`, + (...args) => { + oracleRoute(oracleRouteOf(listenerScenarioFixedSeed)) + return runListenerScenario(...args) + }, + ) - fcTest.prop( - [listenerScenarioArbitrary], - oracleRandomParameters( - generatedRuns, - replay, - `collection-sync.reentrant-drain`, - ), - )( + const listenerScenarioCampaign = oracleRandomParameters( + generatedRuns, + replay, + `collection-sync.reentrant-drain`, + ) + fcTest.prop([listenerScenarioArbitrary], listenerScenarioCampaign)( `matches the reentrant drain laws for a random or replayed seed`, - runListenerScenario, + (...args) => { + oracleRoute(oracleRouteOf(listenerScenarioCampaign)) + return runListenerScenario(...args) + }, ) }) diff --git a/packages/db/tests/comparison-oracle.property.test.ts b/packages/db/tests/comparison-oracle.property.test.ts index dc1ca18558..ce79f6363a 100644 --- a/packages/db/tests/comparison-oracle.property.test.ts +++ b/packages/db/tests/comparison-oracle.property.test.ts @@ -11,9 +11,12 @@ import { } from '../src/utils/comparison' import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplaySkips, + oracleRoute, + oracleRouteOf, oracleRuns, recordOracleReplay, } from './oracle-config.js' @@ -94,7 +97,7 @@ function failureCheckpoint(error: unknown): string { function checkWithOriginalFailure( arbitrary: fc.Arbitrary, check: (value: T) => void, - options: Parameters[1], + options: NonNullable[1]>, replayProperty?: string, ) { let original: ComparisonFailure | undefined @@ -121,7 +124,7 @@ function assertComparisonProperty( label: string, arbitrary: fc.Arbitrary, check: (value: T) => void, - options: Parameters[1], + options: NonNullable[1]>, // The registered property, so a guarded replay can witness this run. replayProperty?: string, ): void { @@ -140,7 +143,7 @@ function assertComparisonProperty( path: result.counterexamplePath, numRuns: 1, endOnFailure: true, - examples: options?.examples, + examples: options.examples, }) const reproduced = replay.result.failed && @@ -150,7 +153,7 @@ function assertComparisonProperty( `${label}: ${original.checkpoint} failed at the function-return checkpoint; ` + `reduction ${reduced.checkpoint === original.checkpoint ? `kept` : `changed`} the original violation; ` + `seed ${result.seed}, path ${result.counterexamplePath}; ` + - `direct replay ${reproduced ? `reproduced` : `did not reproduce`} that violation.\n${result.error}`, + `direct replay ${reproduced ? `reproduced` : `did not reproduce`} that violation.\n${String(result.errorInstance)}`, { cause: original.error }, ) } @@ -171,17 +174,20 @@ function comparisonProperty< } it.skipIf(oracleReplayRequested())(`${label} fixed`, () => { - assertComparisonProperty(label, arbitrary, checkValues, { - seed: fixedSeed, + const parameters = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(campaignRuns), }) + oracleRoute(oracleRouteOf(parameters)) + assertComparisonProperty(label, arbitrary, checkValues, parameters) }) it.skipIf(oracleReplaySkips(property))(`${label} random`, () => { + const parameters = oraclePropertyOptions(campaignRuns, property) + oracleRoute(oracleRouteOf(parameters)) assertComparisonProperty( label, arbitrary, checkValues, - oraclePropertyOptions(campaignRuns, property), + parameters, property, ) }) @@ -413,7 +419,10 @@ function assertTransitive(ab: number, bc: number, ac: number): void { if (ab <= 0 && bc <= 0) expect(ac).toBeLessThanOrEqual(0) } +// What this oracle owns: the gap report judges every function in +// utils/comparison.ts by this file's comparison laws. declareOracle({ + owns: [`src/utils/comparison.ts`], check: [ assertComparisonProperty, checkAntisymmetry, @@ -430,6 +439,7 @@ describe(`comparison law controls`, () => { fcTest( `shrinking retains the first violated law and replays its checkpoint`, () => { + oracleRoute(`calibration`) let reachedOtherLaw = false const arbitrary = fc.integer({ min: 0, max: 10 }) const check = (value: number) => { @@ -470,11 +480,7 @@ describe(`comparison law controls`, () => { `synthetic two-law control`, arbitrary, check, - { - seed: 20260911, - numRuns: 1, - examples: [[5]], - }, + { seed: 20260911, numRuns: 1, examples: [[5]] }, ) } catch (error) { reported = error @@ -494,6 +500,7 @@ describe(`comparison law controls`, () => { ) fcTest(`rejects invalid results and accepts signed infinity`, () => { + oracleRoute(`case`) for (const invalid of [NaN, `invalid`, undefined]) { expect(() => checkAntisymmetry(invalid, 0)).toThrow( `comparator result must be a number other than NaN`, @@ -510,6 +517,7 @@ describe(`comparison law controls`, () => { fcTest( `rejects and replays a comparator returning NaN only for unequal pairs`, () => { + oracleRoute(`calibration`) const mutant = (a: number, b: number): number => (a === b ? 0 : NaN) const property = fc.property(fc.integer({ min: -100, max: 100 }), (a) => { expect(mutant(a, a)).toBe(0) @@ -517,7 +525,7 @@ describe(`comparison law controls`, () => { }) const failure = fc.check(property, { seed: 20260911, numRuns: 20 }) expect(failure.failed).toBe(true) - expect(failure.error).toContain( + expect(String(failure.errorInstance)).toContain( `comparator result must be a number other than NaN`, ) expect(failure.counterexample).toEqual([0]) @@ -531,7 +539,7 @@ describe(`comparison law controls`, () => { endOnFailure: true, }) expect(replay.failed).toBe(true) - expect(replay.error).toContain( + expect(String(replay.errorInstance)).toContain( `comparator result must be a number other than NaN`, ) expect(replay.counterexample).toEqual(failure.counterexample) @@ -541,6 +549,7 @@ describe(`comparison law controls`, () => { fcTest( `changed-byte law rejects length-only equality and accepts indexed equality`, () => { + oracleRoute(`calibration`) const sample = { bytes: new Uint8Array([255]), changed: new Uint8Array([0]), @@ -558,16 +567,19 @@ describe(`comparison law controls`, () => { ) fcTest(`fixed campaign reaches a changed byte in every case`, () => { + const parameters = oracleFixedSeed(20260911, { numRuns: 100 }) + oracleRoute(oracleRouteOf(parameters)) const result = fc.check( fc.property(arbitraryChangedBytes, (sample) => { assertChangedBytes(sample, areValuesEqual) }), - { seed: 20260911, numRuns: 100 }, + parameters, ) expect(result).toMatchObject({ failed: false, numRuns: 100, numSkips: 0 }) }) it(`transitivity reaches a strict chain and rejects a cyclic order`, () => { + oracleRoute(`calibration`) const [a, b, c] = [0, 1, 2] const ab = ascComparator(a, b, defaultOpts) const bc = ascComparator(b, c, defaultOpts) @@ -741,16 +753,19 @@ describe(`ascComparator property-based tests`, () => { ) comparisonProperty([ - // Use bounded dates to avoid overflow when adding offset - fc.date({ min: new Date(0), max: new Date(`2100-01-01`) }), + // Bounded valid dates keep every later date valid after the offset, so + // each generated pair checks the order. fast-check 4 draws Invalid Dates + // unless noInvalidDate is set, and such a draw would check nothing. + fc.date({ + noInvalidDate: true, + min: new Date(0), + max: new Date(`2100-01-01`), + }), fc.integer({ min: 1, max: 1000000 }), ])(`earlier date comes before later date`, (date, offset) => { const later = new Date(date.getTime() + offset) - // Only test if the later date is valid - if (!isNaN(later.getTime())) { - expect(ascComparator(date, later, defaultOpts)).toBeLessThan(0) - expect(ascComparator(later, date, defaultOpts)).toBeGreaterThan(0) - } + expect(ascComparator(date, later, defaultOpts)).toBeLessThan(0) + expect(ascComparator(later, date, defaultOpts)).toBeGreaterThan(0) }) }) @@ -784,6 +799,7 @@ describe(`ascComparator property-based tests`, () => { describe(`NaN and invalid Date default-order refinement`, () => { it(`reconstructs the finite, duplicate NaN, null, and invalid Date witnesses`, () => { + oracleRoute(`case`) assertNonFiniteOrder({ family: `number`, finiteDays: [3, 1, 5], @@ -810,6 +826,7 @@ describe(`NaN and invalid Date default-order refinement`, () => { ) it(`rejects a wrong comparison that ties non-finite and finite values`, () => { + oracleRoute(`calibration`) for (const family of [`number`, `date`] as const) { let reachedFault = false const faultyComparator = ( @@ -848,6 +865,7 @@ describe(`NaN and invalid Date default-order refinement`, () => { }) it(`rejects a default sort that ties NaN to finite values`, () => { + oracleRoute(`calibration`) let reachedFault = false const faultySort = (a: number | Date | null, b: number | Date | null) => { if (typeof a === `number` && Number.isNaN(a)) { @@ -913,8 +931,8 @@ describe(`makeComparator property-based tests`, () => { `makeComparator produces valid comparator for any options (same types)`, ([a, b], direction, nulls) => { const opts: CompareOptions = { - direction: direction as `asc` | `desc`, - nulls: nulls as `first` | `last`, + direction: direction, + nulls: nulls, stringSort: `locale`, } const comparator = makeComparator(opts) @@ -930,6 +948,7 @@ describe(`makeComparator property-based tests`, () => { ) it(`preserves signed infinity from a valid custom string comparator`, () => { + oracleRoute(`case`) const comparator = makeComparator({ ...defaultOpts, stringSort: `custom`, @@ -1036,7 +1055,7 @@ describe(`areValuesEqual property-based tests`, () => { * Equal-value branches construct separate objects, and the zoned branch forces * different zones at the same instant. The production path is the exported * `ascComparator` under default options; each return is the observation - * checkpoint. This lane makes no indexed or public-query claim. + * checkpoint. This production path makes no indexed or public-query claim. */ type IsoDateFields = { year: number; month: number; day: number } type PlainDateOrderInput = { left: IsoDateFields; right: IsoDateFields } @@ -1183,6 +1202,7 @@ const arbitraryZonedDateTimeOrderInput: fc.Arbitrary = describe(`Temporal default-order refinement`, () => { it(`reconstructs the three PlainDate and one ZonedDateTime assertions`, () => { + oracleRoute(`case`) const jan = { year: 2024, month: 1, day: 1 } const june = { year: 2024, month: 6, day: 1 } assertPlainDateOrder({ @@ -1220,6 +1240,7 @@ describe(`Temporal default-order refinement`, () => { ) it(`rejects text order for one instant shown in two zones`, () => { + oracleRoute(`calibration`) const sample: ZonedDateTimeOrderInput = { leftNanoOffset: 0, rightNanoOffset: 0, @@ -1232,6 +1253,7 @@ describe(`Temporal default-order refinement`, () => { }) it(`rejects millisecond truncation across adjacent instants`, () => { + oracleRoute(`calibration`) const sample: ZonedDateTimeOrderInput = { leftNanoOffset: 0, rightNanoOffset: 1, diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index aa495fee01..00a78c72df 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -407,7 +407,7 @@ export interface LiveQueryDriver { */ comparesQueryIdentity: boolean /** No active waivers. Kept empty in drivers to make this boundary explicit. */ - knownGaps?: ReadonlyArray + limits?: ReadonlyArray /** * How the adapter surfaces a query error (see the `error-status` scenario): * - `flag` (default): a readable `isLoadingError`/`status === 'error'` on the result. diff --git a/packages/db/tests/conformance/indexing-config-oracle.ts b/packages/db/tests/conformance/indexing-config-oracle.ts index 90482331a9..35f13b31cd 100644 --- a/packages/db/tests/conformance/indexing-config-oracle.ts +++ b/packages/db/tests/conformance/indexing-config-oracle.ts @@ -138,6 +138,9 @@ function expectIndexing( } } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ grammar: [ indexConfigurations, diff --git a/packages/db/tests/conformance/indexing-reuse-oracle.test.ts b/packages/db/tests/conformance/indexing-reuse-oracle.test.ts index 55abe7e867..109716402e 100644 --- a/packages/db/tests/conformance/indexing-reuse-oracle.test.ts +++ b/packages/db/tests/conformance/indexing-reuse-oracle.test.ts @@ -8,6 +8,7 @@ import { expect, test } from 'vitest' import { BTreeIndex, BasicIndex } from '../../src' import { declareOracle } from '../oracle-declaration' +import { oracleRoute } from '../oracle-config' import { expectIndependentIndexingOutputs, expectedIndexing, @@ -21,7 +22,10 @@ const histories = indexConfigurationPairs( indexTypes: { basic: BasicIndex, btree: BTreeIndex }, }), ) +// Owns no production entry: this file calibrates the shared identity checker +// with planted wrong relations, so it judges no production code. declareOracle({ + owns: ['src/indexes/basic-index.ts', 'src/indexes/btree-index.ts'], grammar: [indexConfigurationPairs], model: [expectedIndexing], check: [expectIndependentIndexingOutputs], @@ -30,6 +34,7 @@ declareOracle({ test.each(histories)( `identity comparison rejects the opposite relation: %j`, ({ first, second }) => { + oracleRoute(`calibration`) const left = expectedIndexing(first).rejects ? undefined : {} const right = expectedIndexing(second).rejects ? undefined : {} if (!left || !right) { @@ -50,6 +55,7 @@ test.each(histories)( test.each([`BasicIndex`, `x`])( `configuration labels survive equal names: %s`, (name) => { + oracleRoute(`case`) const basic = class extends BasicIndex {} const btree = class extends BTreeIndex {} Object.defineProperty(basic, `name`, { value: name }) diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index c56dde64a7..a7ccd178c3 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -180,6 +180,6 @@ export interface InfiniteQueryDriver { build: QueryBuild, config?: InfiniteQueryConfig, ) => InfiniteQueryInputHandle - /** Whole-test waivers are not supported; future gaps need exact signatures. */ - knownGaps?: ReadonlyArray + /** Whole-test waivers are not supported; future limits need exact signatures. */ + limits?: ReadonlyArray } diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 3bab3babbd..350396cf6f 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -22,6 +22,7 @@ */ import { describe, expect, it, vi } from 'vitest' import { createOracleReach } from '../oracle-reach' +import { oracleRoute } from '../oracle-config' import { declareOracle } from '../oracle-declaration.js' import { captureError, @@ -174,6 +175,9 @@ function expectNoPagesAfterRejection(handle: InfiniteQueryHandle): void { * overlapping-window model predicts the committed prefix, its continuation, and * the retained error after overlapping window outcomes. */ +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ check: [expectReadyObservationsMatchSource], model: [expectedOverlappingWindow], @@ -183,7 +187,7 @@ export function runInfiniteQuerySuite( rawDriver: InfiniteQueryDriver, registerUnitScenarios: (context: InfiniteQueryContext) => void, ): void { - const registry = scenarioRegistry(rawDriver.knownGaps) + const registry = scenarioRegistry(rawDriver.limits) const state = createScenarioState() const driver = wrapDriver(rawDriver, state) const lifetime = scenarioLifetime(state) @@ -198,7 +202,14 @@ export function runInfiniteQuerySuite( ) => { registry.register(key) const label = `[${key}] ${name}` - it(label, () => runScenario(state, fn), timeoutMs) + it( + label, + () => { + oracleRoute(`case`) + return runScenario(state, fn) + }, + timeoutMs, + ) } describe(`infinite-query conformance :: ${driver.name}`, () => { diff --git a/packages/db/tests/conformance/page-laws-oracle.ts b/packages/db/tests/conformance/page-laws-oracle.ts index 61b78e2dd1..97ececedd7 100644 --- a/packages/db/tests/conformance/page-laws-oracle.ts +++ b/packages/db/tests/conformance/page-laws-oracle.ts @@ -22,4 +22,7 @@ export function expectPageRows( expectOrderedRows(actual.pages.flat(), actual.data) } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ check: [expectPageRows] }) diff --git a/packages/db/tests/conformance/public-field-oracle.ts b/packages/db/tests/conformance/public-field-oracle.ts index cfba36e9da..89f3803c24 100644 --- a/packages/db/tests/conformance/public-field-oracle.ts +++ b/packages/db/tests/conformance/public-field-oracle.ts @@ -672,6 +672,9 @@ export async function runPublicFieldOverlapHistories( throw new AggregateError(failures, `${host.name}: overlapping projections`) } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ model: [ expectedPublicField, diff --git a/packages/db/tests/conformance/publication-laws-oracle.ts b/packages/db/tests/conformance/publication-laws-oracle.ts index 4fdad0f529..11b4b01884 100644 --- a/packages/db/tests/conformance/publication-laws-oracle.ts +++ b/packages/db/tests/conformance/publication-laws-oracle.ts @@ -38,4 +38,7 @@ export function expectPublishedRows( expectUnorderedRows(values.at(-1), rows) } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ check: [expectPublishedRows] }) diff --git a/packages/db/tests/conformance/registration.ts b/packages/db/tests/conformance/registration.ts index 8f32cd6a4a..0e92606c48 100644 --- a/packages/db/tests/conformance/registration.ts +++ b/packages/db/tests/conformance/registration.ts @@ -1,8 +1,8 @@ /** Whole-test expected failure cannot distinguish a known bug from a new one. */ -export function scenarioRegistry(knownGaps: ReadonlyArray = []) { - if (knownGaps.length) { +export function scenarioRegistry(limits: ReadonlyArray = []) { + if (limits.length) { throw new Error( - `Conformance gaps require an approved exact failure signature, not a whole-test waiver`, + `Conformance limits require an approved exact failure signature, not a whole-test waiver`, ) } const keys = new Set() diff --git a/packages/db/tests/conformance/result-shape-oracle.ts b/packages/db/tests/conformance/result-shape-oracle.ts index 278caa301a..b8bd8c1417 100644 --- a/packages/db/tests/conformance/result-shape-oracle.ts +++ b/packages/db/tests/conformance/result-shape-oracle.ts @@ -10,24 +10,32 @@ * This does not promise synchronous row freshness or identical host scheduling. * Disabled inputs, errors, supplied Collections and DbClient remain with their * existing conformance owners; this grammar uses enabled callback queries. + * + * The property is defined once here and registered once per framework, by + * that framework's conformance suite, with its own driver: one law, model, + * history grammar, and check, judged through each framework's binding. */ import fc from 'fast-check' -import { describe, expect, it } from 'vitest' +import { oracleRuns } from '../oracle-config' import { declareOracle } from '../oracle-declaration' -import { createOracleReach } from '../oracle-reach' -import { - atOracleCheckpoint, - createOracleFailureFidelity, -} from '../oracle-failure-fidelity' -import { - oraclePropertyOptions, - oracleReplayTest, - oracleRuns, -} from '../oracle-config' -import { expectKeyedRows, expectOrderedRows, selectedRow } from './result-laws' +import { differs, oracleProperty, pass, violation } from '../oracle-property' +import { selectedRowKeys } from './result-laws' import { ScenarioLifetime } from './scenario-lifetime' +import type { Observation } from '../oracle-property' import type { ControllableHandle, LiveQueryDriver } from './contract' +// --------------------------------------------------------------------------- +// Histories +// --------------------------------------------------------------------------- +// +// Source cardinality makes absent, single and multiple rows distinguishable. +// Mode changes projection; order changes which row findOne selects. The tail +// permits repeated modes and further returns. No disabled or invalid query is +// generated. Removing either direction or the native cut loses the reported +// stale-projection witness, even when all settled comparisons remain green. +// A finite singleton list and findOne can have identical offset/limit while +// their projections differ. That axis rejects window-only shape invalidation. + type Mode = `list` | `single` type ReadOrder = `data first` | `Collection first` type ShapeRow = { id: string; value: number } @@ -43,14 +51,7 @@ type ShapeHistory = { tail: Array } -// Source cardinality makes absent, single and multiple rows distinguishable. -// Mode changes projection; order changes which row findOne selects. The tail -// permits repeated modes and further returns. No disabled or invalid query is -// generated. Removing either direction or the native cut loses the reported -// stale-projection witness, even when all settled comparisons remain green. -// A finite singleton list and findOne can have identical offset/limit while -// their projections differ. That axis rejects window-only shape invalidation. -const shapeHistory = fc.record({ +const shapeHistory: fc.Arbitrary = fc.record({ initial: fc.constantFrom(`list`, `single`), order: fc.constantFrom(`data first`, `Collection first`), count: fc.integer({ min: 0, max: 4 }), @@ -61,55 +62,62 @@ const shapeHistory = fc.record({ offset: fc.integer({ min: 0, max: 3 }), tail: fc.array(fc.constantFrom(`list`, `single`), { maxLength: 4 }), }) -const examples: Array<[ShapeHistory]> = [3, 1, 0].flatMap((count) => + +// The regressions: every count, initial mode, read order, and window choice, +// plus the adjacent finite offset for the three-row windows. +const regressions: Array = [3, 1, 0].flatMap((count) => ([`list`, `single`] as const).flatMap((initial) => ([`data first`, `Collection first`] as const).flatMap((order) => - [false, true].map((windowed): [ShapeHistory] => [ - { - initial, - order, - count, - base: 0, - spacing: 1, - descending: false, - windowed, - offset: 0, - tail: [], - }, - ]), + [false, true].map((windowed): ShapeHistory => ({ + initial, + order, + count, + base: 0, + spacing: 1, + descending: false, + windowed, + offset: 0, + tail: [], + })), ), ), ) -// Pin the adjacent finite offset as well as zero. Each input still goes through -// the same history grammar, independent model, real driver and comparison. -examples.push( - ...examples - .filter(([history]) => history.count === 3 && history.windowed) - .map(([history]): [ShapeHistory] => [{ ...history, offset: 1 }]), +regressions.push( + ...regressions + .filter((history) => history.count === 3 && history.windowed) + .map((history) => ({ ...history, offset: 1 })), ) +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- +// // The model recomputes from authored source rows and mode. It knows nothing -// about Collection classifiers, observer snapshots, or adapter caches. -function shapeRows( - rows: ReadonlyArray, - mode: Mode, - descending: boolean, - windowed = false, - offset = 0, -) { - const ordered = [...rows].sort((a, b) => - descending ? b.value - a.value : a.value - b.value, +// about Collection classifiers, observer snapshots, or adapter caches. A +// history mounts in its initial mode, switches to the opposite mode twice, +// returns, then follows its tail; each switch has a native cut, where only +// the shape is owed, and a settled cut, where the rows are owed too. + +function sourceRows(history: ShapeHistory): Array { + return Array.from({ length: history.count }, (_, index) => ({ + id: String(index), + value: history.base + index * history.spacing, + })) +} + +function shapeRows(history: ShapeHistory, mode: Mode): Array { + const ordered = [...sourceRows(history)].sort((a, b) => + history.descending ? b.value - a.value : a.value - b.value, ) - const selected = windowed ? ordered.slice(offset, offset + 1) : ordered + const selected = history.windowed + ? ordered.slice(history.offset, history.offset + 1) + : ordered return mode === `single` ? selected.slice(0, 1) : selected } -function checkResultShape(actual: unknown, mode: Mode): void { - expect(Array.isArray(actual), `raw ${mode} projection`).toBe(mode === `list`) - if (mode === `single` && actual !== undefined) { - expect(actual).not.toBeNull() - expect(typeof actual).toBe(`object`) - } +function modes(history: ShapeHistory): Array { + const opposite: Mode = history.initial === `list` ? `single` : `list` + return [history.initial, opposite, opposite, history.initial, ...history.tail] } const states = [ @@ -127,204 +135,400 @@ const states = [ `nonzero finite window projection change`, ] as const -async function checkShapeHistory( - driver: LiveQueryDriver, - history: ShapeHistory, - reach: ReturnType>, -) { - const { initial, order } = history - const lifetime = new ScenarioLifetime() - let handle: ControllableHandle | undefined - lifetime.defer(() => handle?.unmount()) - await lifetime.run(async () => { - const rows = Array.from({ length: history.count }, (_, index) => ({ - id: String(index), - value: history.base + index * history.spacing, - })) - const source = driver.makeSource(rows.map((row) => ({ ...row }))) - lifetime.defer(() => source.collection.cleanup({ dependents: true })) - let compiledMode: Mode | undefined - handle = driver.mountControllable((q, mode) => { - compiledMode = mode - let query = q - .from({ row: source.collection }) - .orderBy( - ({ row }: { row: ShapeRow }) => row.value, - history.descending ? `desc` : `asc`, - ) - if (history.windowed) query = query.offset(history.offset).limit(1) - return mode === `single` ? query.findOne() : query - }, initial) - const mounted = handle - const failures: Array = [] - const compare = (law: string, checkpoint: string, check: () => void) => { - try { - atOracleCheckpoint(law, checkpoint, check) - } catch (error) { - // Keep both directions and the later settled evidence even when the - // native shape is wrong. No failed comparison is discarded. - failures.push(error) - } +type Owed = { mode: Mode; rows?: Array } + +function shapeModel(history: ShapeHistory) { + const sequence = modes(history) + const owed = new Map() + const reached = new Set() + const settled = (checkpoint: string, mode: Mode) => { + owed.set(checkpoint, { mode, rows: shapeRows(history, mode) }) + if (history.count === 0) reached.add(`empty source settled`) + if (history.count > 1) reached.add(`multiple source rows settled`) + } + settled(`initial settled`, history.initial) + for (const [index, mode] of sequence.slice(1).entries()) { + const previous = sequence[index]! + const transition = `${previous} to ${mode}` + owed.set(`${index}: ${transition}; native`, { mode }) + reached.add(`${history.order} read`) + if (previous === mode) reached.add(`repeated mode at native cut`) + else { + reached.add(`${transition} at native cut`) + if (history.count > 0) reached.add(`nonempty ${transition} at native cut`) + if (history.windowed) reached.add(`same finite window projection change`) + if (history.windowed && history.offset > 0) + reached.add(`nonzero finite window projection change`) } - const settled = (mode: Mode, checkpoint: string) => { - const actual = mounted.current() - const expected = shapeRows( - rows, - mode, - history.descending, - history.windowed, - history.offset, - ) - compare(`current query projection`, checkpoint, () => - checkResultShape(actual.data, mode), - ) - compare(`settled selected rows`, checkpoint, () => { - if (mode === `list`) expectOrderedRows(actual.data, expected) - else if (expected.length === 0) expect(actual.data).toBeUndefined() - else { - checkResultShape(actual.data, mode) - expect(selectedRow(actual.data)).toStrictEqual(expected[0]) - } - }) - compare(`settled keyed rows`, checkpoint, () => - expectKeyedRows(actual.state, expected), + settled(`${index}: ${transition}; settled`, mode) + if (index === 2) reached.add(`returned mode settled`) + } + return { owed, reached } +} + +// --------------------------------------------------------------------------- +// Production driver, for one framework's binding +// --------------------------------------------------------------------------- +// +// The driver mounts the query through the framework's controllable binding, +// flushes, and records the settled projection: its shape, its selected rows, +// and the keyed state. For each switch it reads the binding inside the +// framework's native observation callback, in the history's read order, and +// records the shape and the mode the query callback last compiled; after the +// switch settles it records how many native observations ran and the settled +// projection. Rows are recorded as plain selected values, with the enumerable +// symbol keys a binding exposes; the driver decides nothing. Each native +// observation also records the framework's name for its native cut, which a +// failure prints. + +type Shape = `array` | `undefined` | `null` | `object` | `other` +const shapeOf = (value: unknown): Shape => + Array.isArray(value) + ? `array` + : value === undefined + ? `undefined` + : value === null + ? `null` + : typeof value === `object` + ? `object` + : `other` +const plainRow = (row: unknown): unknown => + row !== null && typeof row === `object` + ? Object.fromEntries( + selectedRowKeys(row).map((key) => [ + key, + (row as Record)[key], + ]), ) - if (history.count === 0) reach.reach(`empty source settled`) - if (history.count > 1) reach.reach(`multiple source rows settled`) - } - try { + : row +const byId = (a: { id: unknown }, b: { id: unknown }) => + String(a.id) < String(b.id) ? -1 : String(a.id) > String(b.id) ? 1 : 0 + +type Settled = { + shape: Shape + rows: Array + /** Keyed state as plain entries, or null when the binding exposes none. */ + state: Array<{ id: unknown; value: unknown }> | null +} +type Native = { + /** The framework's native cut, from its conformance driver. */ + cut: string + shape: Shape + compiledMode: Mode | undefined + readError?: string + observations: number +} + +function resultShapeDriver(driver: LiveQueryDriver) { + return async ( + history: ShapeHistory, + observe: (checkpoint: string, value: unknown) => void, + ) => { + const lifetime = new ScenarioLifetime() + let handle: ControllableHandle | undefined + lifetime.defer(() => handle?.unmount()) + await lifetime.run(async () => { + const source = driver.makeSource(sourceRows(history)) + lifetime.defer(() => source.collection.cleanup({ dependents: true })) + let compiledMode: Mode | undefined + handle = driver.mountControllable((q, mode) => { + compiledMode = mode + let query = q + .from({ row: source.collection }) + .orderBy( + ({ row }: { row: ShapeRow }) => row.value, + history.descending ? `desc` : `asc`, + ) + if (history.windowed) query = query.offset(history.offset).limit(1) + return mode === `single` ? query.findOne() : query + }, history.initial) + const mounted = handle + const settled = (checkpoint: string) => { + const { data, state } = mounted.current() + const shape = shapeOf(data) + observe(checkpoint, { + shape, + rows: + shape === `array` + ? (data as Array).map(plainRow) + : shape === `object` + ? [plainRow(data)] + : [], + state: state + ? [...(state as ReadonlyMap).entries()] + .map(([id, value]) => ({ id, value: plainRow(value) })) + .sort(byId) + : null, + } satisfies Settled) + } await mounted.flush() - settled(initial, `initial settled`) - const opposite: Mode = initial === `list` ? `single` : `list` - let previous = initial - for (const [index, mode] of [ - opposite, - opposite, - initial, - ...history.tail, - ].entries()) { + settled(`initial settled`) + const sequence = modes(history) + for (const [index, mode] of sequence.slice(1).entries()) { + const transition = `${sequence[index]} to ${mode}` + let native: Omit | undefined let observations = 0 - const transition = `${previous} to ${mode}` - const checkpoint = `${transition}; ${driver.parameterObservationCut}; ${order}` await mounted.setParam(mode, () => { observations++ - reach.reach(`${order} read`) - // These are real public reads. Their order is part of the history; - // neither a snapshot normalization nor a later flush can repair them. - const actual = atOracleCheckpoint( - `public result read`, - checkpoint, - () => { - if (order === `Collection first`) mounted.collection() - const result = mounted.current() - if (order === `data first`) mounted.collection() - return result - }, - ) - compare(`current input reached query callback`, checkpoint, () => - expect(compiledMode).toBe(mode), - ) - if (previous === mode) reach.reach(`repeated mode at native cut`) - else { - if (history.windowed) - reach.reach(`same finite window projection change`) - if (history.windowed && history.offset > 0) - reach.reach(`nonzero finite window projection change`) - reach.reach( - `${transition} at native cut` as (typeof states)[number], - ) - if (history.count > 0) - reach.reach( - `nonempty ${transition} at native cut` as (typeof states)[number], - ) + // These are real public reads; their order is part of the history. + try { + if (history.order === `Collection first`) mounted.collection() + const result = mounted.current() + if (history.order === `data first`) mounted.collection() + native = { + cut: driver.parameterObservationCut, + shape: shapeOf(result.data), + compiledMode, + } + } catch (error) { + native = { + cut: driver.parameterObservationCut, + shape: `other`, + compiledMode, + readError: String(error), + } } - compare(`current query projection`, checkpoint, () => - checkResultShape(actual.data, mode), - ) }) - compare(`native observation executed`, checkpoint, () => - expect(observations).toBe(1), - ) - settled(mode, `${transition}; settled`) - if (index === 2) reach.reach(`returned mode settled`) - previous = mode + observe(`${index}: ${transition}; native`, { + ...(native ?? { + cut: driver.parameterObservationCut, + shape: `other`, + compiledMode, + }), + observations, + } satisfies Native) + settled(`${index}: ${transition}; settled`) } - } catch (error) { - // A later driver/read interruption cannot replace an earlier mismatch. - failures.push(error) + }) + } +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- +// +// At a native cut: the public read did not throw, exactly one native +// observation ran, the query callback compiled the current mode, and the +// projection has the current mode's shape. At a settled cut: the shape again, +// the selected rows in order (a findOne row or undefined), and the keyed +// state as an unordered set. + +const projects = (shape: Shape, mode: Mode) => + mode === `list` + ? shape === `array` + : shape === `undefined` || shape === `object` + +function checkResultShape( + _history: ShapeHistory, + trace: ReadonlyArray, + { owed }: ReturnType, +) { + for (const { checkpoint, value } of trace) { + const expected = owed.get(checkpoint) + if (!expected) + return violation( + checkpoint, + `the driver recorded a checkpoint the model does not owe`, + `trace`, + ) + if (checkpoint.endsWith(`native`)) { + const native = value as Native + if (native.readError) + return violation( + checkpoint, + `the public read threw: ${native.readError}`, + `public result read`, + ) + if (native.observations !== 1) + return violation( + checkpoint, + `${native.observations} native observations ran`, + `native observation executed`, + ) + if (native.compiledMode !== expected.mode) + return violation( + checkpoint, + `the query callback last compiled ${native.compiledMode}`, + `current input reached query callback`, + ) + if (!projects(native.shape, expected.mode)) + return violation( + checkpoint, + `a ${expected.mode} binding projects ${native.shape}`, + `current query projection`, + { actual: native.shape, expected: expected.mode }, + ) + continue } - if (failures.length) - throw new AggregateError(failures, failures.map(String).join(`\n`), { - cause: failures[0], - }) - }) + const seen = value as Settled + if (!projects(seen.shape, expected.mode)) + return violation( + checkpoint, + `a ${expected.mode} binding projects ${seen.shape}`, + `current query projection`, + { actual: seen.shape, expected: expected.mode }, + ) + // The selected rows are owed in order. An undefined findOne records no + // rows, so an empty findOne that exposes a row fails here. + const rows = expected.rows! + const keyed = rows.map((row) => ({ id: row.id, value: row })).sort(byId) + const wrong = + differs( + checkpoint, + `settled selected rows`, + seen.rows, + rows, + `the selected rows differ from the source model`, + ) ?? + differs( + checkpoint, + `settled keyed rows`, + seen.state, + keyed, + `the keyed state differs from the source model`, + ) + if (wrong) return wrong + } + for (const checkpoint of owed.keys()) + if (!trace.some((entry) => entry.checkpoint === checkpoint)) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}"`, + `trace`, + ) + return pass } -declareOracle({ - model: [shapeRows], - grammar: [shapeHistory], - check: [checkResultShape, checkShapeHistory], +// --------------------------------------------------------------------------- +// Definition and registration +// --------------------------------------------------------------------------- + +// Two constructed histories enter every state the laws need, in every campaign +// and production path. +const resultShapeHistories = { + arbitrary: shapeHistory, + states, + runs: 6, + fixedSeeds: [2316], + constructed: [ + { + name: `every switch over a three-row window`, + enters: states.filter( + (state) => + state !== `Collection first read` && state !== `empty source settled`, + ), + build: (generated: ShapeHistory): ShapeHistory => ({ + ...generated, + initial: `list`, + order: `data first`, + count: 3, + windowed: true, + offset: 1, + tail: [`list`], + }), + }, + { + name: `an empty source read Collection first`, + enters: [`Collection first read`, `empty source settled`], + build: (generated: ShapeHistory): ShapeHistory => ({ + ...generated, + order: `Collection first`, + count: 0, + }), + }, + ], + fixed: regressions.map((history) => ({ + name: `${history.initial}, ${history.order}, ${history.count} rows${history.windowed ? `, window at ${history.offset}` : ``}`, + history, + })), +} + +// Wrong answers the check must reject: the previous mode's projection at a +// native cut, in each direction, and stale rows at a settled cut. Both wrong +// directions keep the selected rows and differ only in array-versus-row +// projection, which row normalization would erase. Each direction corrupts +// the first native cut that projects its mode, so the framework picks a +// candidate history that has one. +const previousProjection = ( + name: string, + current: (shape: Shape) => boolean, + previous: Shape, +) => ({ + name, + law: `current query projection`, + corrupt: (trace: ReadonlyArray) => { + const at = trace.findIndex( + ({ checkpoint, value }) => + checkpoint.endsWith(`native`) && current((value as Native).shape), + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { ...entry, value: { ...(entry.value as Native), shape: previous } } + : entry, + ) + }, }) +const resultShapeCalibrations = [ + previousProjection( + `a list keeps the findOne projection at the native cut`, + (shape) => shape === `array`, + `object`, + ), + previousProjection( + `a findOne keeps the list projection at the native cut`, + (shape) => shape === `object` || shape === `undefined`, + `array`, + ), + { + name: `stale rows at a settled cut`, + law: `settled selected rows`, + corrupt: (trace: ReadonlyArray) => { + const at = trace.findIndex( + ({ checkpoint, value }) => + checkpoint.endsWith(`settled`) && (value as Settled).rows.length > 0, + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { + ...entry, + value: { + ...(entry.value as Settled), + rows: [{ id: `stale`, value: 0 }], + }, + } + : entry, + ) + }, + }, +] export function registerResultShapeOracle(driver: LiveQueryDriver): void { const id = driver.name.replace(/\W+/g, `-`).replace(/-$/, ``) - const property = `framework.${id}.result-shape` - describe(`${driver.name} result-shape conformance`, () => { - for (const campaign of [`fixed`, `random`] as const) { - const test = - campaign === `fixed` - ? oracleReplayTest(it) - : oracleReplayTest(it, property) - test( - `query projection histories (${campaign})`, - async () => { - const reach = createOracleReach(`${property}/${campaign}`, states, { - fixedHistories: examples.length, - }) - const fidelity = createOracleFailureFidelity() - let begun = 0 - try { - await fidelity.assert(() => - fc.assert( - fc.asyncProperty(shapeHistory, async (history) => { - reach.beginHistory({ fixed: begun++ < examples.length }) - await fidelity.run(history, () => - checkShapeHistory(driver, history, reach), - ) - }), - campaign === `fixed` - ? { - seed: 2316, - numRuns: oracleRuns(6) + examples.length, - examples, - } - : oraclePropertyOptions(6, property, { examples }), - ), - ) - reach.assertReached() - } finally { - console.info( - `shape reach ${property}/${campaign}`, - Object.fromEntries(reach.counts()), - ) - } - }, - oracleRuns(30_000), - ) - } - - // Calibrate the same raw-shape judgment with the previous mode's answer. - // Both wrong directions have identical selected rows and differ only in - // array-versus-row projection, which row normalization would erase. - it(`rejects the previous query projection in either direction`, () => { - const rows = [{ id: `0`, value: 0 }] - for (const mode of [`list`, `single`] as const) { - const expected = shapeRows(rows, mode, false) - const good = mode === `list` ? expected : expected[0] - checkResultShape(good, mode) - const wrong = mode === `list` ? expected[0] : expected - expect(() => checkResultShape(wrong, mode)).toThrow() - } - }) + oracleProperty({ + id: `framework.${id}.result-shape`, + law: { + statement: `A binding projects data through its current authored query: a list exposes an array and findOne a row or undefined, at the native cut after a mode change, where only the shape is owed, and after it settles, where the selected rows and keyed state equal the source model.`, + authority: `packages/db/tests/conformance/contract.ts and each adapter's public result type`, + limits: `Enabled callback queries over one source of zero to four rows; disabled inputs, errors, supplied Collections, and DbClient stay with their existing conformance owners. No promise of synchronous row freshness or identical host scheduling.`, + owner: `Frameworks`, + }, + histories: resultShapeHistories, + model: shapeModel, + reached: (_history, _trace, expected) => expected.reached, + driver: resultShapeDriver(driver), + check: checkResultShape, + calibrations: resultShapeCalibrations, + timeout: oracleRuns(30_000), }) } + +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. +declareOracle({ + check: [checkResultShape], + model: [shapeModel], + grammar: [resultShapeHistories], +}) diff --git a/packages/db/tests/conformance/row-snapshot-oracle.ts b/packages/db/tests/conformance/row-snapshot-oracle.ts index 254ffd9930..3b29b1dd9a 100644 --- a/packages/db/tests/conformance/row-snapshot-oracle.ts +++ b/packages/db/tests/conformance/row-snapshot-oracle.ts @@ -577,6 +577,9 @@ export async function runRowSnapshotHistories( }) } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ model: [facts, rowFacts], grammar: [histories], diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 7f728bef7a..1f8df4aa29 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -168,7 +168,7 @@ export function runSuite(rawDriver: LiveQueryDriver) { registerWindowHistoryOracle(rawDriver) registerResultShapeOracle(rawDriver) const { ops } = rawDriver - const registry = scenarioRegistry(rawDriver.knownGaps) + const registry = scenarioRegistry(rawDriver.limits) // Track every handle mounted during the current scenario so it is always torn // down, even when a scenario throws before its own diff --git a/packages/db/tests/conformance/window-history-oracle.ts b/packages/db/tests/conformance/window-history-oracle.ts index c613774083..2de27dc3d4 100644 --- a/packages/db/tests/conformance/window-history-oracle.ts +++ b/packages/db/tests/conformance/window-history-oracle.ts @@ -28,8 +28,11 @@ import { createOracleFailureFidelity, } from '../oracle-failure-fidelity' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config' import { expectOrderedRows, expectUnorderedRows } from './result-laws' @@ -696,20 +699,19 @@ export function registerWindowHistoryOracle(driver: LiveQueryDriver) { }, ) try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2291, { + numRuns: + oracleRuns(12) + windowHistoryExamples.length, + examples: windowHistoryExamples, + }) + : oraclePropertyOptions(12, property, { + examples: windowHistoryExamples, + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => - fc.assert( - propertyUnderTest, - campaign === `fixed` - ? { - numRuns: - oracleRuns(12) + windowHistoryExamples.length, - seed: 2291, - examples: windowHistoryExamples, - } - : oraclePropertyOptions(12, property, { - examples: windowHistoryExamples, - }), - ), + fc.assert(propertyUnderTest, parameters), ) } finally { console.info( @@ -728,6 +730,9 @@ export function registerWindowHistoryOracle(driver: LiveQueryDriver) { }) } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ model: [sameWindowPlan, windowRows], grammar: [windowHistoryArbitrary], @@ -742,7 +747,7 @@ declareOracle({ /** * Pending window moves keep the last settled page. A later commit supersedes * the requested window; an older acquisition cannot publish its own page. This - * receiving lane uses each real adapter over an on-demand source that already + * receiving path uses each real adapter over an on-demand source that already * holds the rows but has not settled its demand. Available rows alone prove no * coverage (ARCHITECTURE.md, atomic window publication). Settlement is explicit; * no timer stands in for provider completion. The core ordered-window owner @@ -931,6 +936,9 @@ async function checkPendingWindowHistory( }) } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ grammar: [pendingWindowArbitrary], check: [checkPendingWindowHistory], @@ -966,20 +974,17 @@ function registerPendingWindowOracle(driver: LiveQueryDriver) { }, ) try { - await fidelity.assert(() => - fc.assert( - propertyUnderTest, - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + pendingExamples.length, - seed: 2291, - examples: pendingExamples, - } - : oraclePropertyOptions(6, property, { - examples: pendingExamples, - }), - ), - ) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2291, { + numRuns: oracleRuns(6) + pendingExamples.length, + examples: pendingExamples, + }) + : oraclePropertyOptions(6, property, { + examples: pendingExamples, + }) + oracleRoute(oracleRouteOf(parameters)) + await fidelity.assert(() => fc.assert(propertyUnderTest, parameters)) } finally { console.info( `pending reach ${driver.name}/${campaign}`, @@ -1000,7 +1005,7 @@ function registerPendingWindowOracle(driver: LiveQueryDriver) { * The application never calls imperative setWindow, whose reentrancy guard is * outside this input language (contract.ts; maintainer decision 2026-10-30). * - * This receiving lane uses all six real framework drivers. A direct Collection + * This receiving path uses all six real framework drivers. A direct Collection * change callback changes the binding's parameter while source publication is * on the stack. The callback entry is measured; no delayed trigger simulates it. * A one-shot guard permits the moved window to publish without another input @@ -1191,20 +1196,17 @@ function registerPublicationWindowOracle(driver: LiveQueryDriver) { }, ) try { - await fidelity.assert(() => - fc.assert( - propertyUnderTest, - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + publicationWindowExamples.length, - seed: 2291, - examples: publicationWindowExamples, - } - : oraclePropertyOptions(6, property, { - examples: publicationWindowExamples, - }), - ), - ) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2291, { + numRuns: oracleRuns(6) + publicationWindowExamples.length, + examples: publicationWindowExamples, + }) + : oraclePropertyOptions(6, property, { + examples: publicationWindowExamples, + }) + oracleRoute(oracleRouteOf(parameters)) + await fidelity.assert(() => fc.assert(propertyUnderTest, parameters)) } finally { console.info( `publication callback reach ${driver.name}/${campaign}`, @@ -1218,6 +1220,9 @@ function registerPublicationWindowOracle(driver: LiveQueryDriver) { } } +// Owns no production entry in packages/db: this module checks the public +// observations that each framework driver passes it, so the framework packages +// that run it own the entries it judges. declareOracle({ grammar: [publicationWindowArbitrary], check: [checkPublicationWindowHistory], @@ -1363,6 +1368,16 @@ function registerPeerWindowOracle(driver: LiveQueryDriver) { jump: number }>() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { + numRuns: oracleRuns(6) + pendingExamples.length, + examples: pendingExamples, + }) + : oraclePropertyOptions(6, property, { + examples: pendingExamples, + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(pendingWindowArbitrary, async (history) => { @@ -1371,15 +1386,7 @@ function registerPeerWindowOracle(driver: LiveQueryDriver) { checkPeerWindowHistory(driver, history, reach), ) }), - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + pendingExamples.length, - seed: 2345, - examples: pendingExamples, - } - : oraclePropertyOptions(6, property, { - examples: pendingExamples, - }), + parameters, ), ) } finally { @@ -1512,6 +1519,11 @@ function registerKeyedAdmissionOracle(driver: LiveQueryDriver) { offset: number }>() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { numRuns: oracleRuns(6) }) + : oraclePropertyOptions(6, property) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(keyedAdmissionArbitrary, async (history) => { @@ -1520,9 +1532,7 @@ function registerKeyedAdmissionOracle(driver: LiveQueryDriver) { checkKeyedAdmission(driver, history, reach), ) }), - campaign === `fixed` - ? { numRuns: oracleRuns(6), seed: 2345 } - : oraclePropertyOptions(6, property), + parameters, ), ) reach.assertReached() @@ -1763,6 +1773,11 @@ function registerImmutableKeyOracle(driver: LiveQueryDriver) { ) const fidelity = createOracleFailureFidelity() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { numRuns: oracleRuns(6) }) + : oraclePropertyOptions(6, property) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(immutableKeyArbitrary, async (history) => { @@ -1771,9 +1786,7 @@ function registerImmutableKeyOracle(driver: LiveQueryDriver) { checkImmutableKeyHistory(driver, history, reach), ) }), - campaign === `fixed` - ? { numRuns: oracleRuns(6), seed: 2345 } - : oraclePropertyOptions(6, property), + parameters, ), ) reach.assertReached() diff --git a/packages/db/tests/cursor-oracle.property.test.ts b/packages/db/tests/cursor-oracle.property.test.ts index 9b0fa3da73..9cf1d504cb 100644 --- a/packages/db/tests/cursor-oracle.property.test.ts +++ b/packages/db/tests/cursor-oracle.property.test.ts @@ -4,8 +4,11 @@ import { createCollection } from '../src/collection/index.js' import { PropRef, Value } from '../src/query/ir.js' import { buildCursor } from '../src/utils/cursor.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -221,7 +224,10 @@ const assertRepeatedCursor = ( ) } +// What this oracle owns: the gap report judges buildCursor and its static +// reference tree by this file's tests. declareOracle({ + owns: [`src/utils/cursor.ts#buildCursor`], check: [expectCursorDenotation, expectLocalTupleOrder, assertRepeatedCursor], model: [compareValue, compareTuple], }) @@ -233,13 +239,14 @@ declareOracle({ // trailing terms determine local order. Null and undefined share a comparator // position but remain distinct driver inputs. Fixed matrices reconstruct every // option cell and small scalar pair; random campaigns extend the integer range. -// Width zero is pinned below as the no-cursor result, outside this generator. +// Width zero is fixed below as the no-cursor result, outside this generator. // A boundary with no order is the nearby invalid state. describe(`buildCursor properties`, () => { it.each(scalarTerms)( `checks every small scalar/null boundary for $direction / nulls $nulls`, (term) => { + oracleRoute(`case`) // All 49 pairs run under every option cell. Null and undefined remain // distinct driver inputs even though their ordering positions coincide. for (const boundary of scalarValues) { @@ -250,25 +257,36 @@ describe(`buildCursor properties`, () => { }, ) - fixedCampaign.prop([scalarCursorArbitrary], { + const scalarCursorFixedSeed = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(400), - seed: fixedSeed, - })( + }) + fixedCampaign.prop([scalarCursorArbitrary], scalarCursorFixedSeed)( `scalar continuation agrees with order for a fixed seed`, - assertScalarCursor, + (...args) => { + oracleRoute(oracleRouteOf(scalarCursorFixedSeed)) + return assertScalarCursor(...args) + }, ) + const scalarCursorCampaign = oraclePropertyOptions( + 400, + `cursor.scalar-continuation`, + ) randomCampaign(`cursor.scalar-continuation`).prop( [scalarCursorArbitrary], - oraclePropertyOptions(400, `cursor.scalar-continuation`), + scalarCursorCampaign, )( `scalar continuation agrees with order for a random or replayed seed`, - assertScalarCursor, + (...args) => { + oracleRoute(oracleRouteOf(scalarCursorCampaign)) + return assertScalarCursor(...args) + }, ) it.each([2, 3, 4])( `rejects composite width %i in every option cell`, (width) => { + oracleRoute(`case`) for (const term of scalarTerms) { expectCursorDenotation( Array.from({ length: width }, () => term), @@ -282,6 +300,7 @@ describe(`buildCursor properties`, () => { it.each([2, 3, 4])( `accepts one leading boundary value for %i ordered terms`, (width) => { + oracleRoute(`case`) const terms = Array.from({ length: width }, () => ({ direction: `asc` as const, nulls: `last` as const, @@ -292,6 +311,7 @@ describe(`buildCursor properties`, () => { ) it(`rejects a builder that accepts two boundary values`, () => { + oracleRoute(`calibration`) const term: Term = { direction: `asc`, nulls: `last` } expect(() => expectCursorDenotation( @@ -305,6 +325,7 @@ describe(`buildCursor properties`, () => { }) it(`orders a leading tie by a nullable trailing term in the public snapshot after preload fulfills`, async () => { + oracleRoute(`fixed history`) const terms: Array = [ { direction: `asc`, nulls: `last` }, { direction: `desc`, nulls: `first` }, @@ -326,6 +347,7 @@ describe(`buildCursor properties`, () => { it.each(scalarTerms)( `rejects constant cursor predicates for $direction / nulls $nulls`, (term) => { + oracleRoute(`calibration`) expect(() => expectCursorDenotation([term], [0], [0], () => new Value(true)), ).toThrow() @@ -344,6 +366,7 @@ describe(`buildCursor properties`, () => { ) it(`rejects an unstable cursor expression on repeated construction`, () => { + oracleRoute(`calibration`) let calls = 0 expect(() => assertRepeatedCursor( @@ -353,80 +376,127 @@ describe(`buildCursor properties`, () => { ).toThrow() }) - fixedCampaign.prop([exactCursorArbitrary], { + const exactCursorFixedSeed = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(300), - seed: fixedSeed, - })(`restricts exact cursor width for a fixed seed`, assertCursorWidth) + }) + fixedCampaign.prop([exactCursorArbitrary], exactCursorFixedSeed)( + `restricts exact cursor width for a fixed seed`, + (...args) => { + oracleRoute(oracleRouteOf(exactCursorFixedSeed)) + return assertCursorWidth(...args) + }, + ) + const exactCursorCampaign = oraclePropertyOptions(300, `cursor.exact-width`) randomCampaign(`cursor.exact-width`).prop( [exactCursorArbitrary], - oraclePropertyOptions(300, `cursor.exact-width`), - )( - `restricts exact cursor width for a random or replayed seed`, - assertCursorWidth, - ) + exactCursorCampaign, + )(`restricts exact cursor width for a random or replayed seed`, (...args) => { + oracleRoute(oracleRouteOf(exactCursorCampaign)) + return assertCursorWidth(...args) + }) - fixedCampaign.prop([exactCursorArbitrary], { + const exactCursorFixedSeed2 = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(300), - seed: fixedSeed, - })( + }) + fixedCampaign.prop([exactCursorArbitrary], exactCursorFixedSeed2)( `preserves exact-width nullable local tuple order for a fixed seed`, - assertLocalTupleOrder, + (...args) => { + oracleRoute(oracleRouteOf(exactCursorFixedSeed2)) + return assertLocalTupleOrder(...args) + }, ) + const exactCursorCampaign2 = oraclePropertyOptions( + 300, + `cursor.exact-local-order`, + ) randomCampaign(`cursor.exact-local-order`).prop( [exactCursorArbitrary], - oraclePropertyOptions(300, `cursor.exact-local-order`), + exactCursorCampaign2, )( `preserves exact-width nullable local tuple order for a random or replayed seed`, - assertLocalTupleOrder, + (...args) => { + oracleRoute(oracleRouteOf(exactCursorCampaign2)) + return assertLocalTupleOrder(...args) + }, ) - fixedCampaign.prop([partialCursorArbitrary], { + const partialCursorFixedSeed = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(200), - seed: fixedSeed, - })( + }) + fixedCampaign.prop([partialCursorArbitrary], partialCursorFixedSeed)( `uses one leading boundary value and rejects other mismatched widths for a fixed seed`, - assertCursorWidth, + (...args) => { + oracleRoute(oracleRouteOf(partialCursorFixedSeed)) + return assertCursorWidth(...args) + }, ) + const partialCursorCampaign = oraclePropertyOptions( + 200, + `cursor.partial-width`, + ) randomCampaign(`cursor.partial-width`).prop( [partialCursorArbitrary], - oraclePropertyOptions(200, `cursor.partial-width`), + partialCursorCampaign, )( `uses one leading boundary value and rejects other mismatched widths for a random or replayed seed`, - assertCursorWidth, + (...args) => { + oracleRoute(oracleRouteOf(partialCursorCampaign)) + return assertCursorWidth(...args) + }, ) - fixedCampaign.prop([partialCursorArbitrary], { + const partialCursorFixedSeed2 = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(200), - seed: fixedSeed, - })( + }) + fixedCampaign.prop([partialCursorArbitrary], partialCursorFixedSeed2)( `preserves partial-width nullable local tuple order for a fixed seed`, - assertLocalTupleOrder, + (...args) => { + oracleRoute(oracleRouteOf(partialCursorFixedSeed2)) + return assertLocalTupleOrder(...args) + }, ) + const partialCursorCampaign2 = oraclePropertyOptions( + 200, + `cursor.partial-local-order`, + ) randomCampaign(`cursor.partial-local-order`).prop( [partialCursorArbitrary], - oraclePropertyOptions(200, `cursor.partial-local-order`), + partialCursorCampaign2, )( `preserves partial-width nullable local tuple order for a random or replayed seed`, - assertLocalTupleOrder, + (...args) => { + oracleRoute(oracleRouteOf(partialCursorCampaign2)) + return assertLocalTupleOrder(...args) + }, ) - fixedCampaign.prop([exactCursorArbitrary], { + const exactCursorFixedSeed3 = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(100), - seed: fixedSeed, - })( + }) + fixedCampaign.prop([exactCursorArbitrary], exactCursorFixedSeed3)( `repeats the same cursor or unsupported-width error for a fixed seed`, - assertRepeatedCursor, + (...args) => { + oracleRoute(oracleRouteOf(exactCursorFixedSeed3)) + return assertRepeatedCursor(...args) + }, ) + const exactCursorCampaign3 = oraclePropertyOptions( + 100, + `cursor.repeat-construction`, + ) randomCampaign(`cursor.repeat-construction`).prop( [exactCursorArbitrary], - oraclePropertyOptions(100, `cursor.repeat-construction`), + exactCursorCampaign3, )( `repeats the same cursor or unsupported-width error for a random or replayed seed`, - assertRepeatedCursor, + (...args) => { + oracleRoute(oracleRouteOf(exactCursorCampaign3)) + return assertRepeatedCursor(...args) + }, ) }) 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 7631d235bc..d29e49be6d 100644 --- a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts +++ b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts @@ -2,8 +2,11 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { expect, it as vitestIt } from 'vitest' import { reconcileChangesForD2 } from '../src/query/live/utils.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -134,7 +137,7 @@ const reconciliationHistoryArbitrary = fc.array(reconciliationStepArbitrary, { maxLength: 30, }) -// Grammar controls: the pinned numeric/string key, unknown update/delete, +// Grammar controls: the fixed numeric/string key, unknown update/delete, // stale previous-value, and changed-source restart histories below are all // reconstructible here. Upsert establishes or replaces authoritative source // rows; rawUpdate exercises an update without a prior sent row; replay checks @@ -361,6 +364,7 @@ function upsert( } it(`keeps numeric and string source keys distinct across restart`, () => { + oracleRoute(`fixed history`) const model = createReconciliationModel() const row = { id: 1, revision: 1, value: 1 } const keys = [0, `0`] as const @@ -406,6 +410,7 @@ it(`keeps numeric and string source keys distinct across restart`, () => { }) it(`preserves external source rows across graph teardown and restart`, () => { + oracleRoute(`fixed history`) const model = createReconciliationModel() const row = { id: 1, revision: 1, value: 1 } @@ -426,6 +431,7 @@ it(`preserves external source rows across graph teardown and restart`, () => { }) it(`replays external source changes made while the graph is down`, () => { + oracleRoute(`fixed history`) const model = createReconciliationModel() const first = { id: 1, revision: 1, value: 1 } const replacement = { id: 1, revision: 2, value: 2 } @@ -467,10 +473,11 @@ function executedReplayReach(steps: ReadonlyArray): number { } it(`executes changed down-state sources and checks their restart replay`, () => { - const histories = fc.sample(reconciliationHistoryArbitrary, { - seed: 1780, - numRuns: 500, - }) + oracleRoute(`fixed seed`) + const histories = fc.sample( + reconciliationHistoryArbitrary, + oracleFixedSeed(1780, { numRuns: 500 }), + ) // Run every history, not just the first syntax match. Each accepted restart // has already passed tracker and signed-relation assertions above. @@ -485,6 +492,7 @@ it(`executes changed down-state sources and checks their restart replay`, () => }) it(`does not count absent replay or an unfinished downtime as replay coverage`, () => { + oracleRoute(`fixed history`) const absent: ReconciliationStep = { type: `batch`, operations: [{ type: `replay`, key: `missing` }], @@ -519,25 +527,37 @@ const assertChangedSourceReplay = (row: SourceRow, key: SourceKey) => { ).toBe(1) } -fixedCampaign.prop([sourceRowArbitrary, sourceKeyArbitrary], { - numRuns: oracleRuns(100), - seed: 1782, -})( +const sourceRowFixedSeed = oracleFixedSeed(1782, { numRuns: oracleRuns(100) }) +fixedCampaign.prop( + [sourceRowArbitrary, sourceKeyArbitrary], + sourceRowFixedSeed, +)( `forces a changed source payload through every generated helper restart for a fixed seed`, - assertChangedSourceReplay, + (...args) => { + oracleRoute(oracleRouteOf(sourceRowFixedSeed)) + return assertChangedSourceReplay(...args) + }, ) +const sourceRowCampaign = oraclePropertyOptions( + 100, + `d2-source.changed-restart`, +) randomCampaign(`d2-source.changed-restart`).prop( [sourceRowArbitrary, sourceKeyArbitrary], - oraclePropertyOptions(100, `d2-source.changed-restart`), + sourceRowCampaign, )( `forces a changed source payload through every generated helper restart for a random or replayed seed`, - assertChangedSourceReplay, + (...args) => { + oracleRoute(oracleRouteOf(sourceRowCampaign)) + return assertChangedSourceReplay(...args) + }, ) it.each([`upsert`, `rawUpdate`, `replay`, `restart`] as const)( `detects driver mutation at %s without changing expected source rows`, (entry) => { + oracleRoute(`calibration`) const model = createReconciliationModel() const row = { id: 1, revision: 1, value: 1 } const step: ReconciliationStep = @@ -589,20 +609,33 @@ const assertExactSourceContributions = ( } } -fixedCampaign.prop([reconciliationHistoryArbitrary], { +const reconciliationHistoryFixedSeed = oracleFixedSeed(1780, { numRuns: oracleRuns(200), - seed: 1780, -})( +}) +fixedCampaign.prop( + [reconciliationHistoryArbitrary], + reconciliationHistoryFixedSeed, +)( `keeps one exact D2 contribution per source key for a fixed seed`, - assertExactSourceContributions, + (...args) => { + oracleRoute(oracleRouteOf(reconciliationHistoryFixedSeed)) + return assertExactSourceContributions(...args) + }, ) +const reconciliationHistoryCampaign = oraclePropertyOptions( + 200, + `d2-source.exact-retractions`, +) randomCampaign(`d2-source.exact-retractions`).prop( [reconciliationHistoryArbitrary], - oraclePropertyOptions(200, `d2-source.exact-retractions`), + reconciliationHistoryCampaign, )( `keeps one exact D2 contribution per source key for a random or replayed seed`, - assertExactSourceContributions, + (...args) => { + oracleRoute(oracleRouteOf(reconciliationHistoryCampaign)) + return assertExactSourceContributions(...args) + }, ) const assertDisjointHistoriesCommute = ([left, right]: [ @@ -620,23 +653,36 @@ const assertDisjointHistoriesCommute = ([left, right]: [ expect(snapshotModel(rightThenLeft)).toEqual(snapshotModel(leftThenRight)) } -fixedCampaign.prop([disjointHistoriesArbitrary], { +const disjointHistoriesFixedSeed = oracleFixedSeed(1781, { numRuns: oracleRuns(100), - seed: 1781, -})( +}) +fixedCampaign.prop([disjointHistoriesArbitrary], disjointHistoriesFixedSeed)( `commutes independent source histories for a fixed seed`, - assertDisjointHistoriesCommute, + (...args) => { + oracleRoute(oracleRouteOf(disjointHistoriesFixedSeed)) + return assertDisjointHistoriesCommute(...args) + }, ) +const disjointHistoriesCampaign = oraclePropertyOptions( + 100, + `d2-source.disjoint-commutation`, +) randomCampaign(`d2-source.disjoint-commutation`).prop( [disjointHistoriesArbitrary], - oraclePropertyOptions(100, `d2-source.disjoint-commutation`), + disjointHistoriesCampaign, )( `commutes independent source histories for a random or replayed seed`, - assertDisjointHistoriesCommute, + (...args) => { + oracleRoute(oracleRouteOf(disjointHistoriesCampaign)) + return assertDisjointHistoriesCommute(...args) + }, ) +// What this oracle owns: the gap report judges reconcileChangesForD2 and its +// static reference tree by this file's tests. declareOracle({ + owns: [`src/query/live/utils.ts#reconcileChangesForD2`], check: [ applyReconciliationStep, expectTrackerMatchesSource, diff --git a/packages/db/tests/db-client-hydration-authority-oracle.test.ts b/packages/db/tests/db-client-hydration-authority-oracle.test.ts index a494dbaaa2..96155fd51d 100644 --- a/packages/db/tests/db-client-hydration-authority-oracle.test.ts +++ b/packages/db/tests/db-client-hydration-authority-oracle.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { DbClient, collectionOptions } from '../src' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration.js' import type { Collection, SyncConfig } from '../src' import type { ChangeMessage } from '../src/types' @@ -63,7 +64,7 @@ import type { ChangeMessage } from '../src/types' * mirror/public agreement. The change-message mirror carries no metadata, so * metadata is observed only through dehydration. * - * Pinned reference cases (not run against production) cover the equal + * Fixed reference cases (not run against production) cover the equal * re-insert, clarification (b) in both arrival orders, a metadata-less latest * seed, and a metadata-only write held against a later seed. The reference * throws on an insert that differs from a row the source wrote; the grammar @@ -72,7 +73,7 @@ import type { ChangeMessage } from '../src/types' * Limits: this owner covers one or two immediately applied adapter * transactions, all of them on the target key, three keys, and at most two * hydration chunks. A source insert over a source-written row appears only in - * the pinned reference cases. `initialData` carries no metadata. The + * the fixed reference cases. `initialData` carries no metadata. The * state-retention oracle and focused DbClient tests own queued transactions and * cancellation. This test does not establish provider transport order or a * later authoritative hydration epoch. @@ -429,7 +430,16 @@ async function runHistory( }) } -declareOracle({ check: [runHistory], model: [referenceRows] }) +// What this oracle owns: the gap report judges DbClient.collection and +// DbClient.applyCollectionChunk by this file's hydration histories. +declareOracle({ + owns: [ + `src/client.ts#DbClient.collection`, + `src/client.ts#DbClient.applyCollectionChunk`, + ], + check: [runHistory], + model: [referenceRows], +}) describe(`DbClient hydration authority oracle`, () => { type Case = { @@ -497,6 +507,7 @@ describe(`DbClient hydration authority oracle`, () => { }) it(`reaches applied delete and truncate with late hydration`, () => { + oracleRoute(`case`) expect(cases).toContainEqual({ decisions: [`delete`], label: `delete`, @@ -529,7 +540,8 @@ describe(`DbClient hydration authority oracle`, () => { ]) }) - it(`reference folds the D5 rules over pinned histories`, () => { + it(`reference folds the D5 rules over fixed histories`, () => { + oracleRoute(`case`) const before = hydrate(hydrationRows(`before`)) const after = hydrate(hydrationRows(`after`)) @@ -612,6 +624,7 @@ describe(`DbClient hydration authority oracle`, () => { }) it(`reference keeps the metadata of an equal re-insert of a source-written row`, () => { + oracleRoute(`case`) expect( targetOf( referenceRows( @@ -640,6 +653,7 @@ describe(`DbClient hydration authority oracle`, () => { }) it(`reference lets seed B supply metadata when a partial update wrote only row data`, () => { + oracleRoute(`case`) // Seed A's `seeded` survives the partial merge; B supplies only the metadata. expect( targetOf( @@ -671,6 +685,7 @@ describe(`DbClient hydration authority oracle`, () => { }) it(`reference merges a partial update over the later seed when both seeds arrive first`, () => { + oracleRoute(`case`) // Seed B replaces A's row whole, so the update's merge base has no `seeded`. expect( targetOf( @@ -688,6 +703,7 @@ describe(`DbClient hydration authority oracle`, () => { }) it(`reference leaves metadata absent when the latest seed names none`, () => { + oracleRoute(`case`) expect( targetOf( referenceRows([seedA], [hydrate([{ value: seedB.value }])], `partial`), @@ -700,6 +716,7 @@ describe(`DbClient hydration authority oracle`, () => { }) it(`reference holds a metadata-only write against a later seed`, () => { + oracleRoute(`case`) expect( targetOf( referenceRows( @@ -717,13 +734,16 @@ describe(`DbClient hydration authority oracle`, () => { it.each(cases)( `keeps $label authoritative with hydration $order its receipt in $rowUpdateMode mode`, - async ({ decisions, order, rowUpdateMode }) => - runHistory(decisions, order, rowUpdateMode), + async ({ decisions, order, rowUpdateMode }) => { + oracleRoute(`case`) + return runHistory(decisions, order, rowUpdateMode) + }, ) // Both seeds carry the same row fields, so the row check cannot tell a whole // replacement from a merge; only the metadata distinguishes them. it(`keeps metadata absent when the latest seed names none, in production`, () => { + oracleRoute(`fixed history`) const row = { id: target, name: `seed`, seeded: `initial` } return runHistory([], `both`, `partial`, (stage) => stage === `before` @@ -810,6 +830,7 @@ describe(`DbClient hydration interleaving oracle`, () => { it.each(interleavings)( `accepts a chunk with persisting=$persisting and source $source writing $sourceKey`, async (history) => { + oracleRoute(`case`) let sync!: SyncActions let release!: () => void const held = new Promise((resolve) => { diff --git a/packages/db/tests/declared-reach-oracle.property.test.ts b/packages/db/tests/declared-reach-oracle.property.test.ts deleted file mode 100644 index 6017b41a31..0000000000 --- a/packages/db/tests/declared-reach-oracle.property.test.ts +++ /dev/null @@ -1,779 +0,0 @@ -/** - * Declared reach (ORC-015) cannot miss by chance where a property fixes it. - * - * A generated property declares the states its laws depend on, and - * `createOracleReach` fails a run in which too few histories entered one. - * These rules decide what a reach counts, when its check runs, and what the - * check proves (#2287): - * - * - A history counts once per declared state it enters. A `reach()` call for - * an undeclared state, or before the first `beginHistory()`, is a driver - * error: it throws and counts nothing. Options that cannot describe a reach - * (a minimum below one, a fractional or negative number of fixed - * histories, a state declared twice) throw when the reach is created. - * - The check fails for each state that fewer than `minimum` histories - * entered. It runs in an ordinary run and in a run with only - * `TANSTACK_DB_ORACLE_SEED`: a seed alone reseeds the whole campaign at its - * full budget. A path replay, which runs one recorded history, skips it in - * every reach of that process, also in a property that the replay does not - * name: a reach need not carry its property's name, so it cannot tell whose - * replay runs. An explicit `replaying` option overrides the environment. - * - A property can run fixed examples first in every run. It declares their - * number as `fixedHistories: n` and begins each example's history with - * `beginHistory({ fixed: true })`. The check then also fails for each state - * that no fixed history entered: such a state's reach would depend on the - * seed. Fixedness belongs to the history, not to its position, so another - * test may share the reach. Shrinking can run an example again, so a fixed - * history beyond the n declared ones counts as a fixed history too. When - * fewer than n fixed histories ran, a failure stopped the run inside the - * examples, and the check reports only that. - * - The examples come on top of the base run budget, so they never displace - * a random history. A replay keeps its seed, and a path replay of the - * property keeps its path and still runs one history. - * - * `runOracleCampaign` runs a fixed-seed and a random campaign, each with its - * own reach and a minimum of one. A seed-only run of its replay prefix runs one - * campaign with that seed and checks its reach; a seed and path run one - * recorded history and check none. Every started campaign reports its seed and - * counts on one `ORACLE_REACH` line, including a failing campaign. Fresh seed - * selection is diagnostic data, not proof that a comparison ran. An invalid - * prefix environment throws. - * - * The model below states these rules over a generated ledger: a run kind, an - * optional `replaying` override, the options, the states reached before the - * first history, each history with its fixed mark and the states it enters, - * the campaign runner's fixed seed, and an optional invalid campaign - * environment. The drivers run `createOracleReach`, `runOracleCampaign`, and - * `oracleRandomParameters` under that run kind's environment, and - * `oraclePropertyOptions` under the process's own. Each check compares one - * whole observation with the model. - */ -import { fc, test as fcTest } from '@fast-check/vitest' -import { afterAll, expect, vi } from 'vitest' -import { createOracleReach } from './oracle-reach.js' -import { runOracleCampaign } from './oracle-campaign.js' -import { - oraclePropertyOptions, - oracleRandomParameters, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' - -const property = `oracle.declared-reach` - -// `other-path` is a path replay of another property, in the same process. -type RunKind = `ordinary` | `seed` | `path` | `other-path` -const otherProperty = `lint.part-reach` -type CampaignFault = `bad-seed` | `path-without-seed` | `bad-path` -type LedgerHistory = { - fixed: boolean - // The indices of the states it enters, with repeats. The index equal to - // the number of declared states names an undeclared one. - states: ReadonlyArray -} -type Ledger = { - run: RunKind - replaying: boolean | undefined - states: ReadonlyArray - minimum: number - fixedHistories: number - // The indices of the states reached before the first history. - early: ReadonlyArray - histories: ReadonlyArray - fixedSeed: number - campaignFault: CampaignFault | undefined -} -// The states a failing reach check names, and an absent failure when the check -// passes or does not run. -type Failure = { - belowMinimum: ReadonlyMap - unfixed: ReadonlyArray - // How many fixed histories ran, when fewer than the declared ones did. - stoppedAt?: number -} -type Rejection = readonly [string, `undeclared` | `early`] -type Verdict = { - // Whether creating the reach threw. - invalid: boolean - // The reach calls that threw, in call order. - rejected: ReadonlyArray - counts: ReadonlyMap - failure: Failure | undefined -} - -const stateName = (index: number) => `state ${index}` - -// The grammar: two to four states, and up to six histories over them. About -// one ledger in twenty has each kind of invalid option, a quarter reach a -// state or two before any history, and about one reach in ten names an -// undeclared state. -const ledgerArbitrary: fc.Arbitrary = fc - .integer({ min: 2, max: 4 }) - .chain((stateCount) => { - const names = Array.from({ length: stateCount }, (_, index) => - stateName(index), - ) - const reachIndex = fc.oneof( - { arbitrary: fc.integer({ min: 0, max: stateCount - 1 }), weight: 9 }, - { arbitrary: fc.constant(stateCount), weight: 1 }, - ) - const mostly = (valid: fc.Arbitrary, invalid: fc.Arbitrary) => - fc.oneof( - { arbitrary: valid, weight: 19 }, - { arbitrary: invalid, weight: 1 }, - ) - return fc.record({ - run: fc.constantFrom(`ordinary`, `seed`, `path`, `other-path`), - replaying: fc.oneof( - { arbitrary: fc.constant(undefined), weight: 3 }, - { arbitrary: fc.boolean(), weight: 1 }, - ), - states: mostly(fc.constant(names), fc.constant([...names, names[0]!])), - minimum: mostly( - fc.integer({ min: 1, max: 3 }), - fc.constantFrom(0, -1, 1.5), - ), - fixedHistories: mostly( - fc.integer({ min: 0, max: 4 }), - fc.constantFrom(-1, 0.5), - ), - early: fc.oneof( - { arbitrary: fc.constant([]), weight: 3 }, - { - arbitrary: fc.array(reachIndex, { minLength: 1, maxLength: 2 }), - weight: 1, - }, - ), - histories: fc.array( - fc.record({ - fixed: fc.boolean(), - states: fc.array(reachIndex, { maxLength: 4 }), - }), - { maxLength: 6 }, - ), - fixedSeed: fc.integer(), - campaignFault: mostly( - fc.constant(undefined), - fc.constantFrom( - `bad-seed`, - `path-without-seed`, - `bad-path`, - ), - ), - }) - }) - -// The model. Invalid options throw before any history. A reach call for an -// undeclared state, or before the first history, throws and counts nothing. -// Every fixed history counts as fixed, also one beyond the declared number, -// which shrinking runs again. A history counts once per declared state it -// enters. -// A path replay of any property checks nothing, unless an explicit -// `replaying: false` says otherwise. Otherwise a state fails below the -// minimum, and, when fixed histories are declared, a state that no fixed -// history entered fails too. When fewer fixed histories ran than were -// declared, a failure stopped the run, and the check names how many ran and -// no state. -function expectedVerdict(ledger: Ledger): Verdict { - const valid = - Number.isInteger(ledger.minimum) && - ledger.minimum >= 1 && - Number.isInteger(ledger.fixedHistories) && - ledger.fixedHistories >= 0 && - new Set(ledger.states).size === ledger.states.length - if (!valid) { - return { - invalid: true, - rejected: [], - counts: new Map(), - failure: undefined, - } - } - const declared = new Set(ledger.states) - const rejected: Array = ledger.early.map((index) => [ - stateName(index), - declared.has(stateName(index)) ? `early` : `undeclared`, - ]) - let fixedBegun = 0 - const begun: Array<{ fixed: boolean; entered: Set }> = [] - for (const history of ledger.histories) { - if (history.fixed) fixedBegun += 1 - const entered = new Set() - for (const index of history.states) { - if (declared.has(stateName(index))) entered.add(stateName(index)) - else rejected.push([stateName(index), `undeclared`]) - } - begun.push({ fixed: history.fixed, entered }) - } - const counts = new Map( - ledger.states.map((state) => [ - state, - begun.filter(({ entered }) => entered.has(state)).length, - ]), - ) - const stopped = fixedBegun < ledger.fixedHistories - const belowMinimum = new Map( - [...counts].filter(([, count]) => count < ledger.minimum), - ) - const unfixed = - ledger.fixedHistories === 0 - ? [] - : ledger.states.filter((state) => - begun.every(({ fixed, entered }) => !fixed || !entered.has(state)), - ) - const failure: Failure | undefined = stopped - ? { belowMinimum: new Map(), unfixed: [], stoppedAt: fixedBegun } - : belowMinimum.size > 0 || unfixed.length > 0 - ? { belowMinimum, unfixed } - : undefined - const checked = - ledger.replaying === undefined - ? ledger.run === `ordinary` || ledger.run === `seed` - : !ledger.replaying - return { - invalid: false, - rejected, - counts, - failure: checked ? failure : undefined, - } -} - -// The environment each run kind sets. -function runEnvironment(run: RunKind): Record { - if (run === `ordinary`) return {} - if (run === `seed`) return { TANSTACK_DB_ORACLE_SEED: `1` } - return { - TANSTACK_DB_ORACLE_SEED: `1`, - TANSTACK_DB_ORACLE_PATH: `0`, - TANSTACK_DB_ORACLE_PROPERTY: run === `path` ? property : otherProperty, - } -} - -// Runs `run` with the given variables set and the other named ones unset, and -// restores the environment afterward. -async function withEnvironment( - names: ReadonlyArray, - variables: Record, - run: () => T | Promise, -): Promise { - for (const name of names) vi.stubEnv(name, variables[name]) - try { - return await run() - } finally { - vi.unstubAllEnvs() - } -} -const replayNames = [ - `TANSTACK_DB_ORACLE_SEED`, - `TANSTACK_DB_ORACLE_PATH`, - `TANSTACK_DB_ORACLE_PROPERTY`, -] - -// The driver: one ledger through createOracleReach. -async function observeLedger(ledger: Ledger): Promise { - let reach: ReturnType> - try { - reach = await withEnvironment(replayNames, runEnvironment(ledger.run), () => - createOracleReach(`ledger`, ledger.states, { - minimum: ledger.minimum, - fixedHistories: ledger.fixedHistories, - ...(ledger.replaying === undefined - ? {} - : { replaying: ledger.replaying }), - }), - ) - } catch { - return { - invalid: true, - rejected: [], - counts: new Map(), - failure: undefined, - } - } - const rejected: Array = [] - const attempt = (state: string) => { - try { - reach.reach(state) - } catch (error) { - const message = (error as Error).message - rejected.push([ - state, - /undeclared reach state/.test(message) - ? `undeclared` - : /before the first beginHistory/.test(message) - ? `early` - : (message as `early`), - ]) - } - } - for (const index of ledger.early) attempt(stateName(index)) - for (const history of ledger.histories) { - reach.beginHistory({ fixed: history.fixed }) - for (const index of history.states) attempt(stateName(index)) - } - let failure: string | undefined - try { - reach.assertReached() - } catch (error) { - failure = (error as Error).message - } - return { - invalid: false, - rejected, - counts: new Map(reach.counts()), - failure: namedStates(failure), - } -} - -// The states a failure names: `name (count)` below the minimum, and a list of -// names for the fixed histories, or how many fixed histories ran. -function namedStates(failure: string | undefined): Failure | undefined { - if (failure === undefined) return undefined - const below = /not enforced there: (.*)$/m.exec(failure)?.[1] - const unfixed = /reach depends on the seed: (.*)$/m.exec(failure)?.[1] - const stoppedAt = /only (\d+) of its \d+ fixed histories ran/.exec( - failure, - )?.[1] - return { - ...(stoppedAt === undefined ? {} : { stoppedAt: Number(stoppedAt) }), - belowMinimum: new Map( - (below?.split(`, `) ?? []).map((entry) => { - const match = /^(.*) \((\d+)\)$/.exec(entry)! - return [match[1]!, Number(match[2])] as const - }), - ), - unfixed: unfixed?.split(`, `) ?? [], - } -} - -// The check: the observed ledger refines the model. -async function checkLedger(ledger: Ledger) { - const expected = expectedVerdict(ledger) - expect(await observeLedger(ledger), `reach verdict`).toEqual(expected) - return expected -} - -// The campaign runner's replay prefix, and the environment each run kind or -// invalid environment sets for it. The runner reads only its own prefix, so a -// replay of another property is an ordinary run for it. -const campaignPrefix = `TANSTACK_DB_REACH_LEDGER` -function campaignRun(run: RunKind): RunKind { - return run === `other-path` ? `ordinary` : run -} -function campaignEnvironment(ledger: Ledger): Record { - const seed = `${campaignPrefix}_SEED` - const path = `${campaignPrefix}_PATH` - if (ledger.campaignFault === `bad-seed`) return { [seed]: `x` } - if (ledger.campaignFault === `path-without-seed`) return { [path]: `0` } - if (ledger.campaignFault === `bad-path`) return { [seed]: `1`, [path]: `a` } - const run = campaignRun(ledger.run) - if (run === `ordinary`) return {} - if (run === `seed`) return { [seed]: `1` } - return { [seed]: `1`, [path]: `0` } -} - -type CampaignLine = { - property: string - campaign: string - minimum: number | null - seed: number - counts: Record -} -type CampaignObservation = { - // The parameters the runner passed to each campaign, in order. - parameters: ReadonlyArray<{ seed?: number; path?: string; numRuns?: number }> - lines: ReadonlyArray - failure: Failure | `invalid environment` | undefined -} - -// The campaign driver replays the ledger's declared reaches, without fixed -// marks or early reaches, and with each state declared once: those rules -// belong to the ledger check above. -function campaignLedger(ledger: Ledger): Ledger { - const states = [...new Set(ledger.states)] - return { - ...ledger, - states, - early: [], - histories: ledger.histories.map((history) => ({ - fixed: false, - states: history.states.filter((index) => index < states.length), - })), - } -} - -// The campaign model: two campaigns without a prefix seed, one replay -// campaign with one. Each campaign has a minimum of one and no fixed -// histories, checks reach unless a path replays it, and reports its seed and counts -// even when reach fails. The first failing campaign ends the run. -function expectedCampaign(ledger: Ledger): CampaignObservation { - if (ledger.campaignFault !== undefined) { - return { parameters: [], lines: [], failure: `invalid environment` } - } - const run = campaignRun(ledger.run) - const campaigns: ReadonlyArray< - readonly [string, { seed?: number; path?: string; numRuns?: number }] - > = - run === `ordinary` - ? [ - [`fixed`, { seed: ledger.fixedSeed }], - [`random`, { seed: expect.any(Number) }], - ] - : [ - [ - `replay`, - run === `seed` ? { seed: 1 } : { seed: 1, path: `0`, numRuns: 1 }, - ], - ] - const replayed = campaignLedger(ledger) - const parameters: Array<{ seed?: number; path?: string; numRuns?: number }> = - [] - const lines: Array = [] - for (const [campaign, campaignParameters] of campaigns) { - parameters.push(campaignParameters) - const { counts, failure } = expectedVerdict({ - ...replayed, - minimum: 1, - fixedHistories: 0, - replaying: run === `path`, - }) - lines.push({ - property: campaignPrefix, - campaign, - minimum: run === `path` ? null : 1, - seed: campaignParameters.seed!, - counts: Object.fromEntries(counts), - }) - if (failure !== undefined) return { parameters, lines, failure } - } - return { parameters, lines, failure: undefined } -} - -// The driver: one ledger through each campaign that runOracleCampaign runs. -function observeCampaign(ledger: Ledger): Promise { - const replayed = campaignLedger(ledger) - return withEnvironment( - [`${campaignPrefix}_SEED`, `${campaignPrefix}_PATH`], - campaignEnvironment(ledger), - async () => { - const parameters: Array<{ - seed?: number - path?: string - numRuns?: number - }> = [] - // The runner reports each campaign's counts on the console. - const info = vi.spyOn(console, `info`).mockImplementation(() => {}) - const lines = () => { - const observations = info.mock.calls - .filter(([line]) => String(line).startsWith(`ORACLE_REACH `)) - .map( - ([line]) => - JSON.parse( - String(line).replace(/^ORACLE_REACH /, ``), - ) as CampaignLine, - ) - for (const [index, line] of observations.entries()) - expect(line.seed, `reported campaign seed`).toBe( - parameters[index]?.seed, - ) - return observations - } - try { - await runOracleCampaign( - campaignPrefix, - replayed.states, - (reach, campaignParameters) => { - parameters.push(campaignParameters) - for (const history of replayed.histories) { - reach.beginHistory() - for (const index of history.states) { - reach.reach(stateName(index)) - } - } - }, - { fixedSeed: ledger.fixedSeed }, - ) - return { parameters, lines: lines(), failure: undefined } - } catch (error) { - const message = (error as Error).message - return { - parameters, - lines: lines(), - failure: /_SEED must be an integer|_PATH requires a seed/.test( - message, - ) - ? (`invalid environment` as const) - : namedStates(message), - } - } finally { - info.mockRestore() - } - }, - ) -} - -// The check: the observed campaigns refine the campaign model. -async function checkCampaign(ledger: Ledger) { - expect(await observeCampaign(ledger), `campaign observation`).toEqual( - expectedCampaign(ledger), - ) -} - -// The run parameters: the examples come on top of the base runs, and the -// replay selection stays. A seed replay keeps its seed, as does a path replay -// of another property. A path replay of this property keeps its seed and path, -// and runs one history. A replay witness channel adds reporter keys; no run -// kind here opens one, so the check leaves them out. -type Selection = { run: RunKind; seed: number; path: string } -function expectedRunParameters( - { run, seed, path }: Selection, - baseRuns: number, - examples: ReadonlyArray, -) { - const numRuns = run === `path` ? 1 : baseRuns + examples.length - if (run === `ordinary`) return { numRuns, seed: expect.any(Number), examples } - if (run === `path`) return { numRuns, seed, path, examples } - return { numRuns, seed, examples } -} - -// oraclePropertyOptions reads the process's replay selection and multiplier -// once, at load. The model reads the same variables on its own. -const processEnvironment = process.env -const processSelection: Selection = { - run: - processEnvironment.TANSTACK_DB_ORACLE_PATH !== undefined - ? processEnvironment.TANSTACK_DB_ORACLE_PROPERTY === property - ? `path` - : `other-path` - : processEnvironment.TANSTACK_DB_ORACLE_SEED === undefined - ? `ordinary` - : `seed`, - seed: Number(processEnvironment.TANSTACK_DB_ORACLE_SEED), - path: processEnvironment.TANSTACK_DB_ORACLE_PATH ?? ``, -} -const processMultiplier = Number( - processEnvironment.TANSTACK_DB_ORACLE_RUNS_MULTIPLIER ?? `1`, -) - -function checkRunParameters( - run: RunKind, - baseRuns: number, - examples: ReadonlyArray, -) { - const project = (parameters: { - numRuns: number - seed?: number - path?: string - examples?: ReadonlyArray - }) => ({ - numRuns: parameters.numRuns, - seed: parameters.seed, - path: parameters.path, - examples: parameters.examples, - }) - const observe = (select: () => T): T => { - const info = vi.spyOn(console, `info`).mockImplementation(() => {}) - try { - const parameters = select() - expect( - Number.isSafeInteger(parameters.seed), - `selected seed is replayable`, - ).toBe(true) - expect( - info.mock.calls.map(([line]) => - JSON.parse(String(line).replace(/^ORACLE_SEED /, ``)), - ), - ).toEqual([{ property, seed: parameters.seed }]) - return parameters - } finally { - info.mockRestore() - } - } - const replayed = run === `path` ? property : otherProperty - // toEqual treats an undefined key as absent. - expect( - project( - observe(() => - oracleRandomParameters( - baseRuns, - run === `ordinary` - ? undefined - : run === `seed` - ? { - replaySeed: 1, - replayPath: undefined, - replayProperty: undefined, - } - : { replaySeed: 1, replayPath: `0`, replayProperty: replayed }, - property, - examples, - ), - ), - ), - `run parameters`, - ).toEqual( - expectedRunParameters({ run, seed: 1, path: `0` }, baseRuns, examples), - ) - expect( - project( - observe(() => oraclePropertyOptions(baseRuns, property, { examples })), - ), - `property options under this process`, - ).toEqual( - expectedRunParameters( - processSelection, - baseRuns * processMultiplier, - examples, - ), - ) -} - -declareOracle({ - check: [checkLedger, checkCampaign, checkRunParameters], - model: [expectedVerdict, expectedCampaign, expectedRunParameters], - grammar: [ledgerArbitrary], -}) - -const reachStates = [ - `a seed-only run fails below the minimum`, - `a path replay with a state below the minimum`, - `a fixed history miss that a later history enters`, - `a state below a minimum above one`, - `a ledger that passes`, - `a reach before the first history`, - `a path replay of another property with a state below the minimum`, - `a run that stopped inside the fixed histories`, - `invalid reach options`, - `a reach of an undeclared state`, - `a replaying option that overrides the environment`, - `a fixed history beyond the declared ones`, - `an unfixed history before the first fixed one`, - `an invalid campaign environment`, -] as const - -// One fixed ledger enters each reach state, first in every run, so this -// property's own reach cannot miss by chance. -const fixedLedger = ( - overrides: Partial, -): [Ledger, number, Array] => [ - { - run: `ordinary`, - replaying: undefined, - states: [stateName(0), stateName(1)], - minimum: 1, - fixedHistories: 0, - early: [], - histories: [], - fixedSeed: 7, - campaignFault: undefined, - ...overrides, - }, - 60, - [1], -] -const history = (fixed: boolean, ...states: Array) => ({ - fixed, - states, -}) -const ledgerExamples = [ - fixedLedger({ run: `seed` }), - fixedLedger({ run: `path` }), - fixedLedger({ - fixedHistories: 1, - histories: [history(true, 0), history(false, 1)], - }), - fixedLedger({ minimum: 2, histories: [history(false, 0, 1)] }), - fixedLedger({ histories: [history(false, 0, 1)] }), - fixedLedger({ early: [0], histories: [history(false, 0, 1)] }), - fixedLedger({ run: `other-path` }), - fixedLedger({ fixedHistories: 3, histories: [history(true, 0, 1)] }), - fixedLedger({ minimum: 0 }), - fixedLedger({ histories: [history(false, 0, 1, 2)] }), - fixedLedger({ replaying: true }), - fixedLedger({ - fixedHistories: 1, - histories: [history(true, 0, 1), history(true, 0)], - }), - fixedLedger({ - fixedHistories: 1, - histories: [history(false, 0), history(true, 0, 1)], - }), - fixedLedger({ campaignFault: `bad-seed`, histories: [history(false, 0, 1)] }), -] -const ledgerExampleInputs = new Set(ledgerExamples.map(([ledger]) => ledger)) -const ledgerReach = createOracleReach(`oracle.declared-reach`, reachStates, { - fixedHistories: ledgerExamples.length, -}) -let ledgersRan = false -afterAll(() => { - if (ledgersRan) ledgerReach.assertReached() -}) - -fcTest.prop( - [ - ledgerArbitrary, - fc.integer({ min: 1, max: 200 }), - fc.array(fc.integer(), { maxLength: 5 }), - ], - oraclePropertyOptions(200, property, { examples: ledgerExamples }), -)( - `reach ledgers refine the declared reach model`, - async (ledger, baseRuns, examples) => { - ledgersRan = true - ledgerReach.beginHistory({ fixed: ledgerExampleInputs.has(ledger) }) - const verdict = await checkLedger(ledger) - await checkCampaign(ledger) - checkRunParameters(ledger.run, baseRuns, examples) - const { counts, failure } = verdict - const ordinary = expectedVerdict({ - ...ledger, - run: `ordinary`, - replaying: undefined, - }).failure - const environmentChecks = ledger.run === `ordinary` || ledger.run === `seed` - if (verdict.invalid) ledgerReach.reach(`invalid reach options`) - if (ledger.campaignFault !== undefined) - ledgerReach.reach(`an invalid campaign environment`) - if (verdict.invalid) return - if ( - ledger.replaying === undefined && - ledger.run === `path` && - (ordinary?.belowMinimum.size ?? 0) > 0 - ) - ledgerReach.reach(`a path replay with a state below the minimum`) - if ( - ledger.replaying === undefined && - ledger.run === `other-path` && - (ordinary?.belowMinimum.size ?? 0) > 0 - ) - ledgerReach.reach( - `a path replay of another property with a state below the minimum`, - ) - if ( - ledger.replaying !== undefined && - ledger.replaying === environmentChecks - ) - ledgerReach.reach(`a replaying option that overrides the environment`) - if (failure?.stoppedAt !== undefined) - ledgerReach.reach(`a run that stopped inside the fixed histories`) - if (ledger.run === `seed` && (failure?.belowMinimum.size ?? 0) > 0) - ledgerReach.reach(`a seed-only run fails below the minimum`) - if (failure?.unfixed.some((state) => (counts.get(state) ?? 0) > 0)) - ledgerReach.reach(`a fixed history miss that a later history enters`) - if (ledger.minimum > 1 && (failure?.belowMinimum.size ?? 0) > 0) - ledgerReach.reach(`a state below a minimum above one`) - if (environmentChecks && ledger.replaying === undefined && !failure) - ledgerReach.reach(`a ledger that passes`) - if (verdict.rejected.some(([, reason]) => reason === `early`)) - ledgerReach.reach(`a reach before the first history`) - if (verdict.rejected.some(([, reason]) => reason === `undeclared`)) - ledgerReach.reach(`a reach of an undeclared state`) - if ( - ledger.histories.filter(({ fixed }) => fixed).length > - ledger.fixedHistories - ) - ledgerReach.reach(`a fixed history beyond the declared ones`) - const firstFixed = ledger.histories.findIndex(({ fixed }) => fixed) - if (ledger.fixedHistories > 0 && firstFixed > 0) - ledgerReach.reach(`an unfixed history before the first fixed one`) - }, -) diff --git a/packages/db/tests/documented-mutations-oracle.test.ts b/packages/db/tests/documented-mutations-oracle.test.ts index 648e6e94ea..ddf953e261 100644 --- a/packages/db/tests/documented-mutations-oracle.test.ts +++ b/packages/db/tests/documented-mutations-oracle.test.ts @@ -5,6 +5,7 @@ * These controls establish judgment sensitivity, not execution of an adapter. */ import { expect, test } from 'vitest' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration' import { checkDocumentedMutation, @@ -69,6 +70,7 @@ const completeRowControls: Array<{ test.each(completeRowControls)( `rejects incomplete ordinary rows: $name`, ({ shape, corrupt }) => { + oracleRoute(`calibration`) const failure = new Error(`API failure`) const selected = histories.filter( (h) => @@ -108,6 +110,7 @@ test.each(completeRowControls)( ) test(`rejects acknowledgement attributed to an API that rejected before returning`, () => { + oracleRoute(`calibration`) const failure = new Error(`API committed then rejected`) const selected = histories.filter( (h) => h.apiOutcome === `reject-after-write`, @@ -149,6 +152,7 @@ test(`rejects acknowledgement attributed to an API that rejected before returnin test.each([`fulfilled`, `rejected`] as const)( `rejects an extracted handler that is %s while its source remains held`, (outcome) => { + oracleRoute(`calibration`) const failure = new Error(`API failure`) const selected = histories.filter( (h) => @@ -197,6 +201,7 @@ test.each([ `wrong error message`, `replaced transaction error`, ])(`rejects an incorrect HTTP-status outcome: %s`, (fault) => { + oracleRoute(`calibration`) const transportFailure = new Error(`transport failed`) const selected = histories.filter((h) => h.failureResponse === `http-status`) expect(selected.length).toBeGreaterThan(0) @@ -240,6 +245,8 @@ test.each([ } }) +// What this oracle owns: no production function. These controls calibrate the +// documented-mutation judgment against planted faults, so the file declares no owns. declareOracle({ check: [checkMutationConfirmation, checkDocumentedMutation], model: [expectedMutationConfirmation], diff --git a/packages/db/tests/documented-mutations-oracle.ts b/packages/db/tests/documented-mutations-oracle.ts index b62724403f..954f2e7f8c 100644 --- a/packages/db/tests/documented-mutations-oracle.ts +++ b/packages/db/tests/documented-mutations-oracle.ts @@ -25,7 +25,7 @@ import type { * * Scope: exact handler ASTs, one mutation, one edited key and an optional peer. * Complete ordinary fields, including Dates and own undefined, are observed. - * Transport rejection and resolved HTTP 503 responses are separate lanes for + * Transport rejection and resolved HTTP 503 responses are separate grammars for * the fetch handler, with server-write fact varied independently. The HTTP * branch owes an Error with its declared message and exact error propagation. * Full schema/factory/query/component execution, awaitMatch, timeout guidance, diff --git a/packages/db/tests/documented-mutations-support.test.ts b/packages/db/tests/documented-mutations-support.test.ts index a8b9e680bc..b9eb0eea96 100644 --- a/packages/db/tests/documented-mutations-support.test.ts +++ b/packages/db/tests/documented-mutations-support.test.ts @@ -87,7 +87,7 @@ test(`finite replay selects the exact authored history and rejects an unknown id }) test.each([undefined, `42`])( - `an irrelevant shared seed %s changes neither complete nor missing-lane reach`, + `an irrelevant shared seed %s changes neither complete nor missing-case reach`, (seed) => { vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, seed) vi.spyOn(console, `info`).mockImplementation(() => {}) diff --git a/packages/db/tests/duration-query-equality-oracle.test.ts b/packages/db/tests/duration-query-equality-oracle.test.ts index 02421aeddb..b8864e38c6 100644 --- a/packages/db/tests/duration-query-equality-oracle.test.ts +++ b/packages/db/tests/duration-query-equality-oracle.test.ts @@ -24,6 +24,7 @@ import { BTreeIndex } from '../src/indexes/btree-index.js' import { eq } from '../src/query/builder/functions.js' import { PropRef } from '../src/query/ir.js' import { mockSyncCollectionOptions } from './utils.js' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' const fields = [ @@ -103,7 +104,13 @@ function expectExactKeys( expect(actual, label).toEqual(expected) } +// What this oracle owns: the exact-key lookups of BasicIndex and BTreeIndex, +// which the public eq read reaches, by this file's Duration cases. declareOracle({ + owns: [ + `src/indexes/basic-index.ts#BasicIndex.lookup`, + `src/indexes/btree-index.ts#BTreeIndex.lookup`, + ], check: [expectExactKeys], model: [modelEqual, modelKeys], }) @@ -117,6 +124,7 @@ const indexTypes = [ // equal copy does not. Three pairs kill text, absolute-value, and first-field // key mutants at the same exact-key-set comparison used below. it(`calibrates all ten fields and three plausible wrong keys`, () => { + oracleRoute(`calibration`) for (const [equal, changed] of Array.from({ length: 10 }, (_, index) => cases.slice(index * 2, index * 2 + 2), )) { @@ -149,6 +157,7 @@ it(`calibrates all ten fields and three plausible wrong keys`, () => { it.each(indexTypes)( `%s exact lookup follows Duration fields`, (_name, IndexType) => { + oracleRoute(`case`) for (const { name, stored, probe } of cases) { const index = new IndexType(1, new PropRef([`value`])) index.add(`row`, { value: stored }) @@ -164,6 +173,7 @@ it.each(indexTypes)( it.each(indexTypes)( `public eq agrees before and after %s indexing`, async (_name, IndexType) => { + oracleRoute(`case`) for (const { name, stored, probe } of cases) { const source = createCollection( mockSyncCollectionOptions({ @@ -199,6 +209,7 @@ it.each(indexTypes)( it.each(indexTypes)( `%s preserves equality indexing for multiple calendar Durations`, async (_name, IndexType) => { + oracleRoute(`case`) const rows = [ { id: `one`, value: Temporal.Duration.from({ years: 1 }) }, { id: `two`, value: Temporal.Duration.from({ years: 2 }) }, diff --git a/packages/db/tests/effect-disposal-oracle.test.ts b/packages/db/tests/effect-disposal-oracle.test.ts index 1a523e3158..b39b74e8e2 100644 --- a/packages/db/tests/effect-disposal-oracle.test.ts +++ b/packages/db/tests/effect-disposal-oracle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createCollection, createEffect } from '../src/index.js' import { createDeferred } from '../src/deferred.js' import { captureMicrotaskErrors, flushPromises } from './utils.js' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' /** @@ -74,7 +75,10 @@ function expectUncaughtReports( ) } +// What this oracle owns: the gap report judges every function in +// query/effect.ts by this file's disposal matrix. declareOracle({ + owns: [`src/query/effect.ts`], check: [expectCallerOutcomes, expectUncaughtReports], model: [expectedUncaughtReports], }) @@ -83,6 +87,7 @@ describe(`Effect disposal outcome oracle`, () => { it.each(scenarios)( `joins all callers to one attempt: %j`, async ({ reentry, pendingHandler, outcome }) => { + oracleRoute(`case`) const failure = new Error(`release failed`) // The thrown value the model expects to see reported, if any. const reported = expectedUncaughtReports(outcome, failure) diff --git a/packages/db/tests/fixtures/census/src/cases.ts b/packages/db/tests/fixtures/census/src/cases.ts index 06bed87d1d..1a320b8c38 100644 --- a/packages/db/tests/fixtures/census/src/cases.ts +++ b/packages/db/tests/fixtures/census/src/cases.ts @@ -1,5 +1,5 @@ // Classifier cases for tests/error-sites-oracle.ts. The production error -// message oracle pins how the census classifies each function below. +// message oracle checks how the census classifies each function below. function devBuild(): boolean { return true 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 e81a44f039..fd3901cb45 100644 --- a/packages/db/tests/flat-change-tracking-oracle.property.test.ts +++ b/packages/db/tests/flat-change-tracking-oracle.property.test.ts @@ -62,10 +62,10 @@ * the 2026-10-29 decision, in both trackers), one that skips deletions * loses removed fields, one that ignores whether the row owns a field drops * added `undefined` fields, and one that edits rows in place breaks the throw - * and stored-draft histories; each fails a pinned history and the fixed + * and stored-draft histories; each fails a fixed history and the fixed * campaign. The proxy used to treat a field added with `undefined` as * reverted when another field went back to its original value, and dropped - * the added field; the pinned history and both campaigns fail without the + * the added field; the fixed history and both campaigns fail without the * fix, because the grammar weights that run. * * Known omissions: nested objects, arrays, Dates, Maps, Sets, class @@ -80,9 +80,12 @@ import { withFlatChangeTracking, } from '../src/proxy.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -374,7 +377,7 @@ const historyArbitrary: fc.Arbitrary = fc }), ) -// Each witness pins a shape the trackers once disagreed on to the change +// Each witness checks a shape the trackers once disagreed on to the change // set both must now report: defining a field acts as assigning it, and a // non-enumerable field is not row data unless the callback writes it. const pinnedHistories: ReadonlyArray<{ name: string; history: History }> = [ @@ -583,22 +586,45 @@ function runHistory(history: History): void { expect(normalize(proxy), `proxy vs model`).toStrictEqual(normalize(model)) } -declareOracle({ check: [runHistory], model: [expectedChanges] }) +// What this oracle owns: the gap report judges the flat and proxy change trackers +// (withFlatChangeTracking, withChangeTracking, withArrayChangeTracking) by this file's tests. +declareOracle({ + owns: [ + `src/proxy.ts#withFlatChangeTracking`, + `src/proxy.ts#withChangeTracking`, + `src/proxy.ts#withArrayChangeTracking`, + ], + check: [runHistory], + model: [expectedChanges], +}) describe(`flat change tracking oracle`, () => { if (!oracleReplayRequested()) { for (const { name, history } of pinnedHistories) { - it(`matches the draft proxy when ${name}`, () => runHistory(history)) + it(`matches the draft proxy when ${name}`, () => { + oracleRoute(`fixed history`) + return runHistory(history) + }) } - fcTest.prop([historyArbitrary], { - seed: 44_502_101, + const historyFixedSeed = oracleFixedSeed(44_502_101, { numRuns: oracleRuns(200), - })(`matches the draft proxy (fixed)`, runHistory) + }) + fcTest.prop([historyArbitrary], historyFixedSeed)( + `matches the draft proxy (fixed)`, + (...args) => { + oracleRoute(oracleRouteOf(historyFixedSeed)) + return runHistory(...args) + }, + ) } // 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) + const historyCampaign = oraclePropertyOptions(200, property) + oracleReplayTest(fcTest, property).prop([historyArbitrary], historyCampaign)( + `matches the draft proxy (random)`, + (...args) => { + oracleRoute(oracleRouteOf(historyCampaign)) + return runHistory(...args) + }, + ) }) diff --git a/packages/db/tests/index-suggestion-oracle.test.ts b/packages/db/tests/index-suggestion-oracle.test.ts index 0f7b5e5bfc..6fe2a0be30 100644 --- a/packages/db/tests/index-suggestion-oracle.test.ts +++ b/packages/db/tests/index-suggestion-oracle.test.ts @@ -22,6 +22,7 @@ import { import { eq } from '../src/query/builder/functions.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { mockSyncCollectionOptions, withOracleCleanup } from './utils.js' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import type { IndexSuggestion } from '../src/indexes/index-registry.js' @@ -67,7 +68,13 @@ function expectedIndexCount( return mode === `eager` || index !== `none` ? 1 : 0 } +// What this oracle owns: the collection-size suggestion and the ordered-index +// fallback warning, by this file's scenarios. declareOracle({ + owns: [ + `src/indexes/index-registry.ts#checkCollectionSizeForIndex`, + `src/query/compiler/order-by.ts#warnOrderIndexFallback`, + ], check: [expectSizeSuggestionOutcome, expectOrderedIndexMessages], model: [ expectedSuggestions, @@ -89,6 +96,7 @@ describe(`collection-size index suggestions`, () => { { mode: `off`, size: 1101, index: `none`, enabled: false }, ] as const) { test(`${scenario.mode}, ${scenario.size} rows, index=${scenario.index}, advice=${scenario.enabled}`, async () => { + oracleRoute(`case`) const suggestions: Array = [] configureIndexDevMode({ enabled: scenario.enabled, @@ -314,6 +322,7 @@ describe(`bounded orderBy index diagnostics`, () => { ] for (const scenario of scenarios) { test(scenario.name, async () => { + oracleRoute(`case`) const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) const source = createCollection( mockSyncCollectionOptions<{ id: number; name: string }>({ diff --git a/packages/db/tests/index-update-oracle.property.test.ts b/packages/db/tests/index-update-oracle.property.test.ts index 02f2ba0847..afe44d7621 100644 --- a/packages/db/tests/index-update-oracle.property.test.ts +++ b/packages/db/tests/index-update-oracle.property.test.ts @@ -8,7 +8,13 @@ import { BTreeIndex } from '../src/indexes/btree-index.js' import { eq } from '../src/query/builder/functions.js' import { PropRef } from '../src/query/ir.js' import { mockSyncCollectionOptions } from './utils' -import { oraclePropertyOptions, oracleRuns } from './oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config' import { declareOracle } from './oracle-declaration' import { indexTypes, withCollectionCleanup } from './index-update-support' import type { BaseIndex } from '../src/indexes/base-index.js' @@ -41,11 +47,11 @@ import type { BaseIndex } from '../src/indexes/base-index.js' * concerns returned index results, not bucket identity or maintenance work. * A build is one of those direct actions even when a source value is * `undefined`: default nulls-first order must hold after build just as it - * does after incremental add. The nullish lane below exercises both paths. - * A separate mixed number/string lane checks key conservation only. A full + * does after incremental add. The nullish grammar below exercises both paths. + * A separate mixed number/string grammar checks key conservation only. A full * ordered read and an indexed public snapshot must contain each live source * key once, even when unlike values have no declared relative sort order. - * A separate two-value callback lane checks legacy scalar input kinds for + * A separate two-value callback grammar checks legacy scalar input kinds for * immutable Date, Temporal.Instant, and byte values across both index types. */ @@ -88,7 +94,7 @@ const arbitraryAction: fc.Arbitrary = fc.oneof( * model actions, not names for additional production index methods. * * Reconstruction: the fixed prefix reaches nonempty replacement, update, - * delete, empty replacement, and continuation; the pinned tail reaches a new + * delete, empty replacement, and continuation; the fixed-history tail reaches a new * key after replacement. Generated put supplies duplicate values and same-key * updates, delete supplies retirement, and build supplies arbitrary replacement * sets. Removing any one action loses that transition. Removing the narrow @@ -226,6 +232,27 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { test.each([20260930, undefined])( `matches a reference model across valid operation sequences (seed %s)`, async (seed) => { + const parameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, `index-update.reference-model`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), + examples: [ + [ + [ + { type: `put`, key: `0`, value: 1 }, + { type: `build`, entries: [[`1`, -1]] }, + { type: `put`, key: `1`, value: 2 }, + ], + ], + [ + [ + { type: `put`, key: `0`, value: 0 }, + { type: `put`, key: `1`, value: -0 }, + ], + ], + ], + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array(arbitraryAction, { minLength: 1, maxLength: 100 }), @@ -280,26 +307,7 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { expectIndexMatchesModel(rebuilt, rows) }, ), - { - ...(seed === undefined - ? oraclePropertyOptions(100, `index-update.reference-model`) - : { seed, numRuns: oracleRuns(100) }), - examples: [ - [ - [ - { type: `put`, key: `0`, value: 1 }, - { type: `build`, entries: [[`1`, -1]] }, - { type: `put`, key: `1`, value: 2 }, - ], - ], - [ - [ - { type: `put`, key: `0`, value: 0 }, - { type: `put`, key: `1`, value: -0 }, - ], - ], - ], - }, + parameters, ) }, ) @@ -307,6 +315,7 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { [`duplicate key`, `takeFromStart`, [`a`, `a`]], [`wrong reverse order`, `takeReversedFromEnd`, [`a`, `b`]], ] as const)(`full traversal rejects a %s`, (_name, method, wrongKeys) => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) index.add(`a`, { value: 1 }) index.add(`b`, { value: 2 }) @@ -327,6 +336,7 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { } }) test(`inclusive lower bound rejects an exclusive-boundary result`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) index.add(`boundary`, { value: -2 }) const rangeQuery = index.rangeQuery.bind(index) @@ -344,6 +354,7 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { } }) test(`upper-only range rejects a dropped minimum when the lower flag is exclusive`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) index.add(`minimum`, { value: -2 }) const rangeQuery = index.rangeQuery.bind(index) @@ -365,6 +376,7 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { } }) test(`explicit exclusive lower bound rejects an included boundary value`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) index.add(`boundary`, { value: -2 }) index.add(`later`, { value: 0 }) @@ -686,7 +698,10 @@ function applyNullishAction( expectNullishMembership(index, rows) } +// What this oracle owns: the gap report judges every function in basic-index.ts and +// btree-index.ts by this file's tests. declareOracle({ + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], check: [ expectIndexMatchesModel, expectCustomGroups, @@ -713,6 +728,7 @@ describe.each(indexTypes)( [`undefined to defined`, undefined, 1], [`undefined to undefined`, undefined, undefined], ] as const)(`keeps membership after %s`, (_label, before, after) => { + oracleRoute(`case`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { type: `put`, key: `a`, value: before }) @@ -720,6 +736,7 @@ describe.each(indexTypes)( }) test(`keeps coexisting null and undefined buckets distinct`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [key, value] of [ @@ -732,6 +749,7 @@ describe.each(indexTypes)( }) test(`finds every undefined owner beside distinct string values`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`status`])) const rows = new Map() const exactProbes = [undefined, `active`, `inactive`] @@ -749,6 +767,7 @@ describe.each(indexTypes)( }) test(`returns no undefined key from a nonempty all-defined index`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { type: `put`, key: `a`, value: 1 }) @@ -756,6 +775,7 @@ describe.each(indexTypes)( }) test(`unites undefined and defined IN members without distractors`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [key, value] of [ @@ -769,6 +789,7 @@ describe.each(indexTypes)( }) test(`returns every undefined owner from a singleton IN list`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [key, value] of [ @@ -781,6 +802,7 @@ describe.each(indexTypes)( }) test(`distinguishes endpoint reads from explicit undefined cursors`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [key, value] of [ @@ -793,6 +815,7 @@ describe.each(indexTypes)( }) test(`traverses every undefined owner before continuing to numbers`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [key, value] of [ @@ -807,6 +830,7 @@ describe.each(indexTypes)( }) test(`retains the second undefined owner after removing the first`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { @@ -823,6 +847,7 @@ describe.each(indexTypes)( }) test(`distinguishes explicit and omitted undefined lower bounds`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [key, value] of [ @@ -836,6 +861,7 @@ describe.each(indexTypes)( }) test(`includes both undefined owners within double undefined bounds`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [key, value] of [ @@ -848,6 +874,7 @@ describe.each(indexTypes)( }) test(`build preserves nulls-first ordered reads and ranges`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { @@ -885,6 +912,11 @@ describe.each(indexTypes)( test.each([20261012, undefined])( `refines nullish membership histories (seed %s)`, async (seed) => { + const campaignParameters = + seed === undefined + ? oraclePropertyOptions(100, `index-update.nullish-membership`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.array(arbitraryNullishAction, { minLength: 1, maxLength: 40 }), @@ -897,14 +929,13 @@ describe.each(indexTypes)( } }, ), - seed === undefined - ? oraclePropertyOptions(100, `index-update.nullish-membership`) - : { seed, numRuns: oracleRuns(100) }, + campaignParameters, ) }, ) test(`rejects a skipped undefined-to-null update`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { @@ -928,6 +959,7 @@ describe.each(indexTypes)( }) test(`rejects lookup dispatch that unites nullish buckets`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { @@ -959,6 +991,7 @@ describe.each(indexTypes)( }) test(`rejects an exact lookup that loses one undefined owner`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`status`])) const rows = new Map([ [`a`, `active`], @@ -984,6 +1017,7 @@ describe.each(indexTypes)( }) test(`rejects an IN lookup that drops an undefined operand`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { @@ -1011,6 +1045,7 @@ describe.each(indexTypes)( }) test(`rejects an IN lookup that loses a same-value owner`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { @@ -1042,6 +1077,7 @@ describe.each(indexTypes)( }) test(`rejects IN dispatch that returns only its first value group`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() applyNullishAction(index, rows, { @@ -1073,6 +1109,7 @@ describe.each(indexTypes)( }) test(`rejects an endpoint read that drops the undefined owner`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map([ [`a`, undefined], @@ -1094,6 +1131,7 @@ describe.each(indexTypes)( }) test(`rejects a cursor that repeats the last undefined owner`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map([ [`undef1`, undefined], @@ -1122,6 +1160,7 @@ describe.each(indexTypes)( }) test(`rejects a forward range that drops an explicit undefined lower bound`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map([ [`undef`, undefined], @@ -1157,6 +1196,7 @@ describe.each(indexTypes)( }) test(`rejects a double undefined range that loses one owner`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map([ [`a`, undefined], @@ -1191,6 +1231,7 @@ describe.each(indexTypes)( }) test(`rejects a null bound that drops its undefined peer`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map([ [`a`, undefined], @@ -1330,6 +1371,7 @@ describe.each(indexTypes)( test.each([0, 1, 4, 5, 32, 33, 65])( `throws exactly when it receives an invalid result after %s rows`, (size) => { + oracleRoute(`case`) const { index, step } = createRecordedIndex(compare) const rows = new Map() for (let value = 0; value < size; value++) { @@ -1344,11 +1386,13 @@ describe.each(indexTypes)( }, ) test(`crashes at the second write of the reported string rows`, () => { + oracleRoute(`fixed history`) const { index, step } = createRecordedIndex((left, right) => left - right) expect(step(() => index.add(`1`, { value: `ann` }))).toBe(false) expect(step(() => index.add(`2`, { value: `bob` }))).toBe(true) }) test(`a rejected remove leaves its row indexed`, () => { + oracleRoute(`fixed history`) const { index, step } = createRecordedIndex((left, right) => left - right) expect(step(() => index.add(`x`, { value: `x` }))).toBe(false) expect(step(() => index.remove(`x`, { value: `x` }))).toBe(true) @@ -1356,6 +1400,7 @@ describe.each(indexTypes)( expect(index.lookup(`eq`, `x`)).toEqual(new Set([`x`])) }) test(`a rejected remove preserves other exact values`, () => { + oracleRoute(`fixed history`) const { index, step } = createRecordedIndex((left, right) => left === `x` && right === `x` ? NaN @@ -1460,6 +1505,7 @@ describe.each([ test.each(writePaths)( `$name crashes the collection instead of publishing partial success`, async ({ write }) => { + oracleRoute(`case`) const { collection, sync } = createNamedCollection() await withCollectionCleanup(collection, () => { const published = new Set() @@ -1517,6 +1563,7 @@ function expectStringMembershipMatches( describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { test(`rejects stale retired identity outputs without rejecting live reuse`, () => { + oracleRoute(`calibration`) const symbol = Symbol(`same group`) const retired = { key: `old`, groupId: 0, value: [symbol] } const live = { key: `live`, groupId: 0, value: [symbol] } @@ -1547,6 +1594,7 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { }) test(`rejects append-only replacement state and accepts replacement continuation`, () => { + oracleRoute(`calibration`) const index = new IndexType(1, new PropRef([`value`])) index.add(`old`, { value: -1 }) index.add(`new`, { value: 1 }) @@ -1562,6 +1610,7 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { }) test(`custom facts reject mutated payload groups and insertion-order ties`, () => { + oracleRoute(`calibration`) const rows = [1, 1, 2].map((groupId, position) => Object.freeze({ key: String(position), @@ -1607,6 +1656,7 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { }) test(`moves exact string membership across a comparator-equal update`, () => { + oracleRoute(`fixed history`) // The input facts put A/a in one order group and B/b in another. The // comparator implements that order, but the expected exact memberships // come from the source Map's unchanged strings, not from the comparator. @@ -1689,18 +1739,25 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { // Group grammar: 2..20 generated IDs in 0..4, with a forced duplicate first // group and a distinct group 5. The same bounded input is run in both index - // types. The exact-identity lane gives every row a fresh array, then retires - // and reuses handles. The custom-comparator lane additionally reverses + // types. The exact-identity grammar gives every row a fresh array, then retires + // and reuses handles. The custom-comparator grammar additionally reverses // insertion. Without the duplicate, representative retirement is absent; // without the distinct group, group order is unobservable; without reversed // insertion, insertion order can masquerade as lexical key order. Replacing // one value with a fresh value in the same group checks exact membership // independently of comparator position; a later update crosses groups. - // The pinned [0, 1] case reconstructs these distinctions. Invalid comparator + // The fixed [0, 1] history reconstructs these distinctions. Invalid comparator // results and inconsistent comparator order are outside these two grammars. test.each([20260930, undefined])( `preserves exact equality while ordered traversal retains every row (seed %s)`, async (seed) => { + const runParameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, `index-update.exact-identity`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), + examples: [[[0, 1]]], + } + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty( fc.array(fc.integer({ min: 0, max: 4 }), { @@ -1823,12 +1880,7 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { expectMatchesModel(index, [previous]) }, ), - { - ...(seed === undefined - ? oraclePropertyOptions(100, `index-update.exact-identity`) - : { seed, numRuns: oracleRuns(100) }), - examples: [[[0, 1]]], - }, + runParameters, ) }, ) @@ -1836,6 +1888,13 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { test.each([20260930, undefined])( `matches an independent custom-comparator model (seed %s)`, async (seed) => { + const parameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, `index-update.custom-comparator`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), + examples: [[[0, 1]]], + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array(fc.integer({ min: 0, max: 4 }), { @@ -1949,18 +2008,14 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { } }, ), - { - ...(seed === undefined - ? oraclePropertyOptions(100, `index-update.custom-comparator`) - : { seed, numRuns: oracleRuns(100) }), - examples: [[[0, 1]]], - }, + parameters, ) }, ) }) test(`retired-identity calibration shrinks and replays the same semantic failure`, () => { + oracleRoute(`calibration`) const property = fc.property( fc.array(fc.integer({ min: 0, max: 20 }), { minLength: 1, maxLength: 8 }), (keys) => { @@ -1974,7 +2029,7 @@ test(`retired-identity calibration shrinks and replays the same semantic failure ) const failed = fc.check(property, { seed: 303108, numRuns: 1 }) expect(failed.failed).toBe(true) - expect(failed.error).toMatch(/expected/) + expect(String(failed.errorInstance)).toMatch(/expected/) expect(failed.counterexample).toEqual([[0]]) expect(failed.counterexamplePath).toBe(`0:0:0`) if (failed.counterexamplePath === null) @@ -1986,7 +2041,7 @@ test(`retired-identity calibration shrinks and replays the same semantic failure endOnFailure: true, }) expect(replay.failed).toBe(true) - expect(replay.error).toMatch(/expected/) + expect(String(replay.errorInstance)).toMatch(/expected/) expect(replay.counterexample).toEqual(failed.counterexample) }) @@ -2001,7 +2056,7 @@ test(`retired-identity calibration shrinks and replays the same semantic failure * then deletes one Temporal owner. After each returned action, both full index * traversals must contain exactly the live source keys. * - * In the public lane a ready Collection receives the same rows and deletes + * In the public production path a ready Collection receives the same rows and deletes * the number. A manual index serves a bounded `currentStateAsChanges` read * with room for one more row than the source contains. The returned change * messages must contain each live key once. `optimizedOnly` and the take spy @@ -2035,6 +2090,7 @@ describe.each(indexTypes)( test.each(mixedRows)( `keeps every surviving key once after deleting $id`, (removed) => { + oracleRoute(`case`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const row of mixedRows) { @@ -2049,6 +2105,7 @@ describe.each(indexTypes)( ) test(`keeps one copy of each key after a mixed-value delete, update, and add`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() const add = (id: string, value: number | string) => { @@ -2087,6 +2144,7 @@ describe.each(indexTypes)( ])( `keeps $name and ordinary-string owners after deletion`, ({ first, second, text }) => { + oracleRoute(`case`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() for (const [id, value] of [ @@ -2111,6 +2169,7 @@ describe.each([ [`BTreeIndex`, BTreeIndex], ] as const)(`%s mixed-value public snapshot`, (_indexName, IndexType) => { test(`returns each surviving key once after a source deletion`, async () => { + oracleRoute(`fixed history`) const options = mockSyncCollectionOptions<{ id: string value: number | string @@ -2195,6 +2254,7 @@ function expectBinaryValueMembership( describe.each(indexTypes)(`%s binary-value ownership`, (_name, IndexType) => { test(`does not retain an ordered entry after equal-content removal`, () => { + oracleRoute(`fixed history`) const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() const bytes = (value = 1) => new Uint8Array([value]) @@ -2254,6 +2314,7 @@ describe.each(indexTypes)( `%s unordered Temporal equality`, (_name, IndexType) => { test(`accepts distinct PlainMonthDay values for exact lookup`, async () => { + oracleRoute(`fixed history`) const rows = [ { id: `jan`, value: Temporal.PlainMonthDay.from(`--01-01`) }, { id: `feb`, value: Temporal.PlainMonthDay.from(`--02-01`) }, diff --git a/packages/db/tests/indexed-db/ORACLE.md b/packages/db/tests/indexed-db/ORACLE.md index 7354b82f1b..66b6334372 100644 --- a/packages/db/tests/indexed-db/ORACLE.md +++ b/packages/db/tests/indexed-db/ORACLE.md @@ -42,12 +42,13 @@ membership. Empty-store delete first inserts. Import chooses empty or one-row replacement. Manual acceptance writes overlapping keys with distinct values in two stores. Every history ends with fresh restores. -The authored reconstruction history visits every action in both stores. -Pinned cases retain generated update/clear and repeated no-op histories. -Matrices reconstruct CRUD × decision, persistence-entry × bad-row-position, -and remote delete/clear × local rollback/commit. Transport cases retain the old +The authored reconstruction history visits every action in both stores. Fixed +histories retain generated update/clear and repeated no-op histories. Matrices +reconstruct CRUD × decision, persistence-entry × bad-row-position, and remote +delete/clear × local rollback/commit. Transport cases retain the old rapid/concurrent and routing coverage and add the twelve histories below. -Missing-key updates and duplicate imported keys have explicit rejection witnesses. +Missing-key updates and duplicate imported keys have explicit rejection +witnesses. Each axis contributes independently: @@ -204,12 +205,12 @@ introduced. ## Campaigns and direct replay -The normal test command runs the same property, generator, recorder, driver and -30-run budget twice: fixed seed 1179001, then no supplied seed. Each campaign -reports its actual seed, run count and outcome. Fast-check reports counterexample -and shrink path; verbose output retains failed histories. A reduced trace proves -its reported checkpoint; do not label a reduction a reproduction of a different -facet without replaying both. +The normal test command runs the same property, generator, recorder, production +driver and 30-run budget twice: fixed campaign seed 1179001, then no supplied +campaign seed. Each campaign reports its actual campaign seed, run count and +outcome. Fast-check reports counterexample and shrink path; verbose output +retains failed histories. A reduced trace proves its reported checkpoint; do not +label a reduction a reproduction of a different facet without replaying both. From this package directory: @@ -285,7 +286,7 @@ The executable companions keep distinct responsibilities: | `pending-history-oracle.test.ts` | One held multi-row intent, durable rows, exposed base, queued source batches and acknowledgement attribution; public/raw/downstream snapshots at handler entry, each peer step and settlement | 24 CRUD × peer action × decision cases, with all actual peer CRUD operations reached; 30+30 generated histories of up to four peer actions, 1–3 local rows; stress 300+300 | | `persistence-oracle.test.ts` | Three distinct Collection owners, mixed deletes/inserts, two stores and version isolation after each acceptance | All six acceptance orders; no cross-Collection atomicity or same-ID identity policy | | `settlement.test.ts` (fixed tests; no oracle owner (tracked in #2264)) | Failure before, within and after valid rows in a three-row native batch | First/middle/last clone failures × automatic/manual/import, each a `SerializationError`, with populated replacement rollback | -| `e2e/indexed-db/cross-tab-oracle.spec.ts` | Native IndexedDB and BroadcastChannel, independent same-origin pages, runner-owned raw evidence | Chromium, Firefox and WebKit; pinned receiving matrix and 10 fixed + 10 fresh histories per engine | +| `e2e/indexed-db/cross-tab-oracle.spec.ts` | Native IndexedDB and BroadcastChannel, independent same-origin pages, runner-owned raw evidence | Chromium, Firefox and WebKit; fixed receiving matrix and 10 fixed + 10 random-campaign histories per engine | The transport model combines exposed base and public rows only when local mutations have settled. The pending companion keeps them separate. It adapts @@ -419,7 +420,7 @@ non-truncating full-row comparison. Automatic writes intentionally wait for their predecessor; they can no longer supply that old fixture schedule. Runtime/type tests resolve workspace source with no dependency build. The -packed-package lane tests ESM/CJS consumers after complete builds. +packed-package production path tests ESM/CJS consumers after complete builds. The review evidence and guide audit are in [`2026-10-05-indexeddb-xhigh.md`](../../../../docs/contributing/oracle-reviews/2026-10-05-indexeddb-xhigh.md). diff --git a/packages/db/tests/indexed-db/campaign.ts b/packages/db/tests/indexed-db/campaign.ts index 95d7a92955..b23a041888 100644 --- a/packages/db/tests/indexed-db/campaign.ts +++ b/packages/db/tests/indexed-db/campaign.ts @@ -148,7 +148,7 @@ export async function runCampaign( ) if (result.failed) throw new Error( - `${name} failed: seed=${result.seed} path=${selectedPath}; replay=${JSON.stringify(reproduction)}\n${result.error}`, + `${name} failed: seed=${result.seed} path=${selectedPath}; replay=${JSON.stringify(reproduction)}\n${String(result.errorInstance)}`, ) if (result.numRuns !== (options.path === undefined ? options.runs : 1)) throw new Error('Campaign did not execute its declared budget') diff --git a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts index e7de82d620..4ff5a9fd40 100644 --- a/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts +++ b/packages/db/tests/indexed-db/cross-tab-boundary-oracle.test.ts @@ -10,6 +10,8 @@ */ import { expect, it } from 'vitest' import { createCollection, indexedDBCollectionOptions } from '../../src' +import { oracleRoute } from '../oracle-config' +import { declareOracle } from '../oracle-declaration' import { Channel, seed, withHarness } from './harness' import { holdStore, observeTransactions } from './idb-driver' import { @@ -21,6 +23,14 @@ import { import { recordCollection } from './recorder' import type { Row } from './harness' +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], +}) + async function omission(fault: 'none' | 'partial' | 'error') { await withHarness(async (h) => { const before = { id: 0, name: 'before', optional: 7 } @@ -92,15 +102,19 @@ async function omission(fault: 'none' | 'partial' | 'error') { }) } -it('removes an omitted whole-row field after ordered delete and reinsert', () => - omission('none')) +it('removes an omitted whole-row field after ordered delete and reinsert', () => { + oracleRoute(`fixed history`) + return omission('none') +}) it('rejects partial-row application at the omission checkpoint', async () => { + oracleRoute(`calibration`) await expect(omission('partial')).rejects.toMatchObject({ law: 'public rows', checkpoint: 'whole-row omission', }) }) it('rejects spurious Collection error despite correct rows and fulfilled receivers', async () => { + oracleRoute(`calibration`) await expect(omission('error')).rejects.toMatchObject({ law: 'Collection status', checkpoint: 'receiver completion', @@ -113,6 +127,7 @@ for (const serial of [false, true]) { ? 'the serial fixture fails the receiver-overlap reach control' : 'dispatch admits two real receiver reads while the first is pending', async () => { + oracleRoute(`case`) await withHarness(async (h) => { const writer = await h.open() const descriptor = await h.connect() @@ -165,6 +180,7 @@ for (const serial of [false, true]) { for (const abort of [false, true]) { it(`an obsolete receiver ${abort ? 'failure' : 'success'} cannot affect a restarted sync run`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { const writer = await h.open() const descriptor = await h.connect() @@ -209,6 +225,7 @@ for (const abort of [false, true]) { } it('reports an active receiver abort as the Collection error while its callback fulfills', async () => { + oracleRoute(`fixed history`) await withHarness(async (h) => { const writer = await h.open() const descriptor = await h.connect() @@ -246,6 +263,7 @@ it('reports an active receiver abort as the Collection error while its callback }) it('an imported replacement never publishes a union with the prior snapshot', async () => { + oracleRoute(`fixed history`) await withHarness(async (h) => { const before = [{ id: 'old', name: 'old' }] const after = [ @@ -277,6 +295,7 @@ it('an imported replacement never publishes a union with the prior snapshot', as for (const deliver of [false, true]) it(`a stale peer ${deliver ? 'rejects an update after deletion publication' : 'can author an update before deletion publication'}`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { const before = { id: 0, name: 'old' } await seed(h.db, 'items', [before]) 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 d6903b9340..4fc946081b 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 @@ -8,7 +8,7 @@ * history of whole effects, with raw event and public status checks. Existing * peers and an ordinary successful suffix precede fresh restore. * - * Fixed/random campaigns share the exact property and bounds. Pinned histories + * Fixed/random campaigns share the exact property and bounds. Fixed histories * reconstruct every action; finite templates enumerate every sender/delivery * choice within their stated two-operation scope. This is not exhaustive native * scheduling, arbitrary optimistic overlap, or a same-key conflict policy. @@ -19,11 +19,15 @@ import { join } from 'node:path' import { fc } from '@fast-check/vitest' import { expect, it, vi } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplaySkips, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' +import { declareOracle } from '../oracle-declaration' import { runCampaign } from './campaign' import { runTransportHistory } from './cross-tab-driver' import { @@ -35,6 +39,14 @@ import { } from './cross-tab-oracle' import type { TransportStep } from './cross-tab-driver' +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], +}) + const kinds = [ 'write', 'delete', @@ -88,22 +100,25 @@ const crossTabProperty = `indexeddb.cross-tab-history` it.skipIf(oracleReplayRequested())( 'refines independent per-Collection snapshots with fixed histories', - () => campaign(oracleRuns(runs), 11792026), + () => { + oracleRoute(`fixed seed`) + return campaign(oracleRuns(runs), 11792026) + }, 120_000, ) it.skipIf(oracleReplaySkips(crossTabProperty))( 'refines independent per-Collection snapshots with fresh histories', () => { - const { numRuns, seed, path } = oraclePropertyOptions( - runs, - crossTabProperty, - ) + const parameters = oraclePropertyOptions(runs, crossTabProperty) + oracleRoute(oracleRouteOf(parameters)) + const { numRuns, seed, path } = parameters return campaign(numRuns, seed, path) }, 120_000, ) it('reconstructs every action and overlapping receiver reads', async () => { + oracleRoute(`fixed history`) const reached = await runTransportHistory([ action('write', 0), action('delete', 0), @@ -119,6 +134,7 @@ it('reconstructs every action and overlapping receiver reads', async () => { }) it('enumerates two ordered writes with every intermediate delivery choice', async () => { + oracleRoute(`case`) let explored = 0 // 2 first writers × 2 second writers × 4 delivery subsets = 16 schedules. for (const first of [0, 1]) @@ -135,6 +151,7 @@ it('enumerates two ordered writes with every intermediate delivery choice', asyn }, 60_000) it('requires local admission to distinguish identical durable deletions', () => { + oracleRoute(`fixed history`) const row = { id: 0, name: 'old' } const delayed = new CrossTabModel([0, 0], [[row]]) delayed.accept(0, { rows: [], deletes: [0], replace: false }) @@ -154,6 +171,7 @@ it('requires local admission to distinguish identical durable deletions', () => }) it('rejects repaired partial publications and malformed raw deltas', () => { + oracleRoute(`calibration`) const before = [ { id: 0, name: 'a' }, { id: 1, name: 'b' }, @@ -217,6 +235,7 @@ it('rejects repaired partial publications and malformed raw deltas', () => { }) it('replays a generated intentional mismatch at its original law and checkpoint', () => { + oracleRoute(`calibration`) const property = fc.property(fc.integer({ min: 1, max: 20 }), (value) => assertSnapshot( [{ id: value, name: 'wrong' }], @@ -238,11 +257,14 @@ it('replays a generated intentional mismatch at its original law and checkpoint' premiseReached: replay.numRuns > 0, sameFailure: replay.failed && - String(replay.error).includes('public rows at replay calibration'), + String(String(replay.errorInstance)).includes( + 'public rows at replay calibration', + ), }) }) it('rejects wrong raw event keys before folding including duplicate malformed inserts', () => { + oracleRoute(`calibration`) const row = { id: 1, name: 'accepted value' } const effect = { rows: [row], deletes: [], replace: false } for (const key of ['wrong', '1']) { @@ -283,9 +305,11 @@ it('replays only the selected history after the original failure is repaired', a // This shrink path has a later sibling. fast-check check() continues into // that sibling when the selected counterexample passes, despite numRuns: 1. const arbitrary = fc.integer({ min: 0, max: 100 }) + const parameters = oracleFixedSeed(11792027, { numRuns: 30 }) + oracleRoute(oracleRouteOf(parameters)) const original = fc.check( fc.property(arbitrary, (value) => value < 10), - { seed: 11792027, numRuns: 30 }, + parameters, ) expect(original.counterexample).toEqual([10]) expect(original.counterexamplePath).toBe('0:1:0:0:1') @@ -303,6 +327,7 @@ it('replays only the selected history after the original failure is repaired', a }) it('preserves campaign outcomes and reports without revision metadata', async () => { + oracleRoute(`fixed seed`) // Revision lookup is diagnostic. It cannot turn passing histories red or // replace the original failed law. Both results must retain their reports. const directory = await mkdtemp(join(tmpdir(), 'indexeddb-no-git-')) diff --git a/packages/db/tests/indexed-db/local-write-order-oracle.test.ts b/packages/db/tests/indexed-db/local-write-order-oracle.test.ts index 2d91e17660..efbbe8d7a2 100644 --- a/packages/db/tests/indexed-db/local-write-order-oracle.test.ts +++ b/packages/db/tests/indexed-db/local-write-order-oracle.test.ts @@ -19,6 +19,7 @@ */ import { expect, it } from 'vitest' import { declareOracle } from '../oracle-declaration' +import { oracleRoute } from '../oracle-config' import { initial } from './local-write-support' import { Channel, @@ -68,13 +69,21 @@ function expectedRows(effects: Array, mask: number, before = initial) { } // expectedRows is the model: the durable fold of the authored effects that the // mask accepts, in author order, from the rows that the peer action leaves. -declareOracle({ model: [expectedRows] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + model: [expectedRows], +}) for (const [kind, effects] of Object.entries(grammars)) { for (const peerAction of ['none', 'update', 'delete', 'replace'] as const) for (const order of orders) for (let mask = 0; mask < 8; mask++) { it(`${kind} persists accepted effects after peer ${peerAction} in mutation order (${order.join(',')}; mask ${mask})`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { await seed(h.db, 'items', initial) const gates = effects.map(() => deferred()), diff --git a/packages/db/tests/indexed-db/pending-history-oracle.test.ts b/packages/db/tests/indexed-db/pending-history-oracle.test.ts index 9f9f429abe..6e2b49ab9f 100644 --- a/packages/db/tests/indexed-db/pending-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/pending-history-oracle.test.ts @@ -15,7 +15,7 @@ * The driver uses independent descriptors, deferred application handlers, public * base/metadata, raw IDB observations and a simple downstream live query. * Checkpoints: handler entry, every peer read/publication, settlement, ordinary - * suffix and restore. Pinned CRUD × remote action × decision cases reconstruct + * suffix and restore. Fixed CRUD × remote action × decision cases reconstruct * every boundary. Fixed/fresh histories use the same generator and checker. */ import { fc } from '@fast-check/vitest' @@ -24,8 +24,11 @@ import { createLiveQueryCollection } from '../../src' import { oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config' +import { declareOracle } from '../oracle-declaration' import { Channel, deferred, @@ -46,6 +49,15 @@ import { runCampaign } from './campaign' import type { Change, OracleRow } from './cross-tab-oracle' import type { Operation } from './harness' +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], +}) + // Campaigns. The fixed campaign and the fresh campaign run the same generator // and checker. A path replay of `indexeddb.pending-history` runs its recorded // history once, and every other test in this file stands aside. A seed alone @@ -392,6 +404,7 @@ async function run( } it('reconstructs held multi-row CRUD crossed with every peer action and decision', async () => { + oracleRoute(`case`) const reach = new Set() for (const local of ['insert', 'update', 'delete'] as const) for (const kind of ['write', 'delete', 'clear', 'import'] as const) @@ -409,6 +422,7 @@ it('reconstructs held multi-row CRUD crossed with every peer action and decision expect(reach.has(`peer ${operation}`), operation).toBe(true) }, 60_000) it('attributes the queued confirmation after an earlier replacement', async () => { + oracleRoute(`fixed history`) const history: History = { local: 'insert', accept: true, @@ -429,6 +443,7 @@ it('attributes the queued confirmation after an earlier replacement', async () = }) it('a peer no-op cannot spend another source acknowledgement', async () => { + oracleRoute(`fixed history`) await run({ local: 'insert', accept: false, @@ -443,6 +458,7 @@ it('a peer no-op cannot spend another source acknowledgement', async () => { const runs = process.env.TANSTACK_INDEXEDDB_ORACLE_PROFILE === 'stress' ? 300 : 30 it(`refines held mutations through fixed peer histories`, async () => { + oracleRoute(`fixed seed`) await runCampaign( 'pending', histories, @@ -453,7 +469,9 @@ it(`refines held mutations through fixed peer histories`, async () => { oracleReplayTest(vitestIt, PENDING_HISTORY)( `refines held mutations through fresh peer histories`, async () => { - const { numRuns, seed, path } = oraclePropertyOptions(runs, PENDING_HISTORY) + const parameters = oraclePropertyOptions(runs, PENDING_HISTORY) + oracleRoute(oracleRouteOf(parameters)) + const { numRuns, seed, path } = parameters await runCampaign( 'pending', histories, diff --git a/packages/db/tests/indexed-db/persistence-oracle.test.ts b/packages/db/tests/indexed-db/persistence-oracle.test.ts index 14bd0950ff..9d60069c3e 100644 --- a/packages/db/tests/indexed-db/persistence-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-oracle.test.ts @@ -24,8 +24,11 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { createTransaction } from '../../src' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, recordOracleReplay, } from '../oracle-config' @@ -257,6 +260,7 @@ describe('settled histories', () => { // Reconstruction witnesses preserve all action kinds even if random sampling // or shrinking selects an empty history. it('reconstructs replacement, ownership, typed keys and restart histories', async () => { + oracleRoute(`fixed history`) await runHistory( kinds.flatMap((kind, i) => [ { kind, slot: 0, key: i % 4, value: 1, optional: true }, @@ -265,12 +269,14 @@ describe('settled histories', () => { ) }) it('clears a previously confirmed update without retaining optimistic data', async () => { + oracleRoute(`fixed history`) await runHistory([ { kind: 'write', slot: 0, key: 0, value: 0, optional: false }, { kind: 'clear', slot: 0, key: 0, value: 0, optional: false }, ]) }) it('refreshes changed imported rows while allowing identical replacements', async () => { + oracleRoute(`fixed history`) const replacement: Step = { kind: 'import', slot: 0, @@ -285,6 +291,7 @@ describe('settled histories', () => { ]) }) it('preserves rows through authored no-op updates', async () => { + oracleRoute(`fixed history`) const repeated: Step = { kind: 'write', slot: 0, @@ -303,6 +310,7 @@ describe('settled histories', () => { parameters: { numRuns: number; seed?: number; path?: string }, ) => { return async () => { + oracleRoute(oracleRouteOf(parameters)) console.info('indexeddb.persistence campaign', mode, parameters) await fc.assert(fc.asyncProperty(history, runHistory), { verbose: true, @@ -325,7 +333,10 @@ describe('settled histories', () => { } oracleReplayTest(it)( 'matches authored data through fixed histories', - settledCampaign(`fixed`, { numRuns: oracleRuns(30), seed: 1179001 }), + settledCampaign( + `fixed`, + oracleFixedSeed(1179001, { numRuns: oracleRuns(30) }), + ), 30_000, ) oracleReplayTest(it, settledProperty)( @@ -355,6 +366,12 @@ function acceptOwnerInModel( // peer and version rows with the authored model that it keeps for the history. // acceptOwnerInModel is the model of the manual-acceptance test below. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + 'src/transactions.ts#createTransaction', + ], check: [runHistory], model: [acceptOwnerInModel], }) @@ -366,6 +383,7 @@ declareOracle({ * same-ID Collection policy. A skipped owner must leave its rows/versions alone. */ it('manual acceptance partitions mixed payloads across three Collections in every order', async () => { + oracleRoute(`case`) for (const order of [ [0, 1, 2], [0, 2, 1], diff --git a/packages/db/tests/indexed-db/persistence-values-oracle.test.ts b/packages/db/tests/indexed-db/persistence-values-oracle.test.ts index 610bc38200..a4510c3960 100644 --- a/packages/db/tests/indexed-db/persistence-values-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-values-oracle.test.ts @@ -18,6 +18,8 @@ import { createIndexedDB, indexedDBCollectionOptions, } from '../../src' +import { oracleRoute } from '../oracle-config' +import { declareOracle } from '../oracle-declaration' import { Channel, deferred, readStore, withHarness } from './harness' import { createValue, @@ -28,8 +30,17 @@ import { import { holdStore } from './idb-driver' import type { ValueRow } from './structured-clone-oracle' +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], +}) + for (const kind of valueKinds) { it(`preserves ${kind} through updates, peers, export/import and restore`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { const descriptors = [h.db, await h.connect()] const make = (db: typeof h.db) => { @@ -133,6 +144,7 @@ for (const kind of valueKinds) { // Calibrate the value observation independently of storage. These wrong answers // preserve superficial contents while losing exactly the type/offset/byte law. it('rejects flattened values, lost offsets, corrupt bytes and absent rows', async () => { + oracleRoute(`case`) const row = (value: unknown) => [ { id: 1, name: 'initial', nested: { value } }, ] @@ -166,6 +178,7 @@ it('rejects flattened values, lost offsets, corrupt bytes and absent rows', asyn for (const kind of valueKinds.filter((value) => value !== 'blob')) { for (const entry of ['update', 'import'] as const) { it(`captures ${kind} before delayed ${entry} persistence`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { const handler = deferred() h.disposers.push(() => handler.resolve()) diff --git a/packages/db/tests/indexed-db/persistence.test.ts b/packages/db/tests/indexed-db/persistence.test.ts index 213719b3f3..139ef72508 100644 --- a/packages/db/tests/indexed-db/persistence.test.ts +++ b/packages/db/tests/indexed-db/persistence.test.ts @@ -1,5 +1,5 @@ // Ordinary tests for the settled-history owner in persistence-oracle.test.ts. -// They pin legacy version-metadata semantics and reject wrong snapshots with +// They check legacy version-metadata semantics and reject wrong snapshots with // literal values. The law they support is that version tokens, not wall-clock // timestamps, order changes, and that assertRows distinguishes typed keys and // extra fields. The model and check that the owner declares are in diff --git a/packages/db/tests/indexed-db/retirement-oracle.test.ts b/packages/db/tests/indexed-db/retirement-oracle.test.ts index f5f45dda43..f9daac3e87 100644 --- a/packages/db/tests/indexed-db/retirement-oracle.test.ts +++ b/packages/db/tests/indexed-db/retirement-oracle.test.ts @@ -21,6 +21,7 @@ import { describe, expect, it, vi } from 'vitest' import { createTransaction } from '../../src' import { declareOracle } from '../oracle-declaration' +import { oracleRoute } from '../oracle-config' import { assertRows, deferred, @@ -56,7 +57,16 @@ function apply(rows: Array, kind: Kind, owner: number): Array { return [...rows.filter((value) => value.id !== row.id), row] } -declareOracle({ model: [apply] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db-wrapper.ts#deleteDatabase', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + 'src/transactions.ts#createTransaction', + ], + model: [apply], +}) const pairCells = kinds.flatMap((first) => kinds.flatMap((second) => @@ -67,6 +77,7 @@ const pairCells = kinds.flatMap((first) => ) describe('admitted native writes retain their actual outcome after closure', () => { it.each(pairCells)('%j', async ({ first, second, reason, reverse }) => { + oracleRoute(`case`) // Both queued aborts and FIFO commits are reachable, including abort of the // later transaction before the earlier transaction finishes. for (const outcomes of [ @@ -202,6 +213,7 @@ describe('admitted native writes retain their actual outcome after closure', () for (const reason of reasons) { for (const kind of ['insert', 'update', 'delete'] as const) { it(`publishes accepted ${kind} before caller settlement after ${reason}`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { await seed(h.db, 'items', initial) const c = await h.open() @@ -258,6 +270,7 @@ for (const reason of reasons) { for (const kind of kinds) { it(`confirms native-committed ${kind} if explicit close precedes its continuation`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { await seed(h.db, 'items', initial) const c = await h.open() @@ -318,6 +331,7 @@ const mixedCells = prefixes.flatMap((a) => it.each(mixedCells)( 'retains independent operation prefixes at closure: %j', async ({ a, b, reason, abort }) => { + oracleRoute(`case`) await withHarness(async (h) => { await seed(h.db, 'items', initial) const operations = [a, b] diff --git a/packages/db/tests/indexed-db/transport-oracle.test.ts b/packages/db/tests/indexed-db/transport-oracle.test.ts index e21e9b4068..7d0e2592bf 100644 --- a/packages/db/tests/indexed-db/transport-oracle.test.ts +++ b/packages/db/tests/indexed-db/transport-oracle.test.ts @@ -20,6 +20,7 @@ import { expect, it } from 'vitest' import { executeTransaction } from '../../src' import { declareOracle } from '../oracle-declaration' +import { oracleRoute } from '../oracle-config' import { Channel, assertRows, readStore, seed, withHarness } from './harness' import type { Row } from './harness' @@ -48,6 +49,7 @@ for (const replacement of ['clear', 'import'] as const) { delivery + ' notifications', async () => { + oracleRoute(`case`) await withHarness(async (h) => { await seed(h.db, 'items', [{ id: 'old', name: 'replaced' }]) const source = await h.open() @@ -103,6 +105,12 @@ function expectedAfterPeerEdit( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db-wrapper.ts#executeTransaction', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], model: [expectedAfterReplacement, expectedAfterPeerEdit], }) @@ -113,6 +121,7 @@ for (const metadata of ['none', 'mixed', 'all'] as const) { for (const key of [1, '1']) for (const action of ['update', 'delete'] as const) { it(`reconciles ${action} of ${typeof key} keys with ${metadata} initial versions`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { const initial = [ { id: key, name: 'legacy' }, diff --git a/packages/db/tests/indexed-db/wrapper-settlement-oracle.test.ts b/packages/db/tests/indexed-db/wrapper-settlement-oracle.test.ts index f582a41753..3f8944ce95 100644 --- a/packages/db/tests/indexed-db/wrapper-settlement-oracle.test.ts +++ b/packages/db/tests/indexed-db/wrapper-settlement-oracle.test.ts @@ -22,6 +22,7 @@ import { expect, it } from 'vitest' import { executeTransaction } from '../../src' import { declareOracle } from '../oracle-declaration' +import { oracleRoute } from '../oracle-config' import { deferred, readStore, withHarness } from './harness' type NativeOutcome = 'complete' | 'abort' @@ -36,7 +37,15 @@ function expectedCaller( if (native === 'abort') return 'rejected' return callbackFulfilled ? 'fulfilled' : 'pending' } -declareOracle({ model: [expectedCaller] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db-wrapper.ts#executeTransaction', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + model: [expectedCaller], +}) for (const native of ['complete', 'abort'] as const) { for (const registration of [ @@ -48,6 +57,7 @@ for (const native of ['complete', 'abort'] as const) { ] as const) { for (const heldCallback of [false, true]) { it(`preserves ${native} settlement with ${registration} observers and held callback ${heldCallback}`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { const row = { id: 1, name: 'authored' } const callback = deferred() @@ -134,6 +144,7 @@ for (const native of ['complete', 'abort'] as const) { // The authored seed survives either history; only the valid sibling is conditional. for (const cancelRequestError of [false, true]) { it(`uses terminal outcome after a request error canceled ${cancelRequestError}`, async () => { + oracleRoute(`case`) await withHarness(async (h) => { const seed = { id: 1, name: 'retained' } const sibling = { id: 2, name: 'valid sibling' } diff --git a/packages/db/tests/live-query-bound-options-oracle.ts b/packages/db/tests/live-query-bound-options-oracle.ts index ac31c74301..956f6c84f8 100644 --- a/packages/db/tests/live-query-bound-options-oracle.ts +++ b/packages/db/tests/live-query-bound-options-oracle.ts @@ -217,7 +217,7 @@ export function checkBoundSchemaMutation( ) } -// Every lane is required separately in each native receiving driver. A held +// Every production path is required separately in each native receiving driver. A held // observation counts only after immediate/public/handler comparisons succeed. export const boundSchemaCuts = [ `initial-source-raw`, @@ -233,11 +233,11 @@ export const boundSchemaCuts = [ `rebound-followup-held`, `rebound-followup-settled`, ] as const -export function boundSchemaLane(history: BoundSchemaHistory) { +export function boundSchemaGrammar(history: BoundSchemaHistory) { return `${history.identity}/${history.firstSchema}->${history.nextSchema}/${history.changeKey ? `new-key` : `same-key`}/${history.input.word}` } export const boundSchemaReachStates = boundSchemaHistories.flatMap((history) => - boundSchemaCuts.map((cut) => `${boundSchemaLane(history)}/${cut}`), + boundSchemaCuts.map((cut) => `${boundSchemaGrammar(history)}/${cut}`), ) declareOracle({ diff --git a/packages/db/tests/live-query-bound-options-support.ts b/packages/db/tests/live-query-bound-options-support.ts index 048aa410af..ea27625b27 100644 --- a/packages/db/tests/live-query-bound-options-support.ts +++ b/packages/db/tests/live-query-bound-options-support.ts @@ -9,7 +9,7 @@ import { gt } from '../src/query/index.js' import { createDeferred } from '../src/deferred.js' import { mockSyncCollectionOptions, withOracleCleanup } from './utils.js' import { - boundSchemaLane, + boundSchemaGrammar, captureBoundOptions, checkBoundSchemaMutation, checkBoundSchemaRows, @@ -154,8 +154,8 @@ export async function runBoundSchemaHistory( reach: ReturnType>, ): Promise { reach.beginHistory() - const lane = boundSchemaLane(history) - const mark = (cut: string) => reach.reach(`${lane}/${cut}`) + const grammar = boundSchemaGrammar(history) + const mark = (cut: string) => reach.reach(`${grammar}/${cut}`) let base: Array = [ { id: `one`, label: ` source alpha `, rank: 1 }, { id: `peer`, label: ` source beta `, rank: 2 }, diff --git a/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts b/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts index 42cf1b5249..ee008135e0 100644 --- a/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts +++ b/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts @@ -21,6 +21,7 @@ import { getLiveQueryHash, resolveLiveQueryValue, } from '../src' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration' import { createOracleReach } from './oracle-reach' import { withOracleCleanup } from './utils' @@ -112,7 +113,13 @@ afterAll(() => { ), ) }) +// What this oracle owns: the gap report judges DbClient.dehydrate and +// resolveLiveQueryValue by this file's claim-lifecycle histories. declareOracle({ + owns: [ + `src/client.ts#DbClient.dehydrate`, + `src/live-query-options.ts#resolveLiveQueryValue`, + ], check: [ checkLifecycleAdmission, checkCacheOptionRows, @@ -146,6 +153,7 @@ test.each( )( `cache claim lifetime %j`, async ({ history, claimant, phase, pending, replaceBeforeRelease }) => { + oracleRoute(`case`) const reach = reaches[claimant]! reach.beginHistory() const server = new DbClient() @@ -461,6 +469,7 @@ test.each( test.each([`getKey`, `schema`] as const)( `portable cache rejects a leaked %s function`, (option) => { + oracleRoute(`calibration`) expect(() => checkPortableCacheState({ [option]: () => {} })).toThrow() }, ) @@ -470,6 +479,7 @@ test.each([`getKey`, `schema`] as const)( test.each(phases)( `lifecycle checker rejects the opposite claim policy at %s`, (phase) => { + oracleRoute(`calibration`) const history = cacheOptionHistories.find(conflictingCacheOptions)! const wrong = () => { if (!claimSurvives(phase)) throw new Error(`getKey conflict`) diff --git a/packages/db/tests/live-query-cache-options-oracle.test.ts b/packages/db/tests/live-query-cache-options-oracle.test.ts index dddebf3447..fa40329686 100644 --- a/packages/db/tests/live-query-cache-options-oracle.test.ts +++ b/packages/db/tests/live-query-cache-options-oracle.test.ts @@ -12,7 +12,7 @@ * returned Promise; request start and unresolved preload are checked explicitly. * Public admission, dehydration rows, observer delivery, and subscriber ownership * are the observation cuts. React supplies client/server receiving paths using - * the same judgment. Failure/consumption retirement is a separate lifecycle lane. + * the same judgment. Failure/consumption retirement is a separate lifecycle grammar. */ import { afterAll, expect, test, vi } from 'vitest' import { @@ -23,6 +23,7 @@ import { getLiveQueryHash, resolveLiveQueryValue, } from '../src' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration' import { createOracleReach } from './oracle-reach' import { withOracleCleanup } from './utils' @@ -80,7 +81,13 @@ afterAll(() => { ), ) }) +// What this oracle owns: the gap report judges prepareLiveQueryValue and +// resolveLiveQueryValue by this file's admission histories. declareOracle({ + owns: [ + `src/live-query-options.ts#prepareLiveQueryValue`, + `src/live-query-options.ts#resolveLiveQueryValue`, + ], check: [ checkCacheOptionAdmission, checkCacheOptionPromise, @@ -100,6 +107,7 @@ test.each( ), ), )(`cache admission history %j`, async ({ history, held, path, claimant }) => { + oracleRoute(`case`) const route = `${claimant}/${path}` executedRoutes.add(route) const reach = reaches[route]! @@ -384,12 +392,14 @@ test.each( test.each( cacheOptionHistories.filter((history) => conflictingCacheOptions(history)), )(`cache admission rejects hostile acceptance %j`, (history) => { + oracleRoute(`calibration`) for (let attempt = 0; attempt < 2; attempt++) expect(() => checkCacheOptionAdmission(() => {}, history)).toThrow() }) test.each([`first`, `other`] as const)( `cache rows reject hostile keys and leading publication for %s`, (key) => { + oracleRoute(`calibration`) const rows = [{ id: `one`, label: `authored` }] const correct = [{ key: `${key}-one`, value: rows[0] }] const wrong = [{ key: `borrowed`, value: rows[0] }] @@ -406,6 +416,7 @@ test.each([`first`, `other`] as const)( test.each([`pending`, `fulfilled`, `unrelated-error`, `conflict`] as const)( `rejects wrong pre-delivery admission outcome: %s`, async (outcome) => { + oracleRoute(`calibration`) const history = cacheOptionHistories.find(conflictingCacheOptions)! const result = outcome === `pending` diff --git a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts index b549f74352..7b50f1e890 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -47,7 +47,7 @@ * first subscriber. The separate sibling-resumption grammar below uses * controlled asynchronous loads. The broad grammar does not generate source * failure, asynchronous loads, or several live-query consumers. The - * subscription lifecycle oracle owns those once acquisition has resumed. Pinned blocks cover pooled views, + * subscription lifecycle oracle owns those once acquisition has resumed. Fixed blocks cover pooled views, * `DbClient` stream preloads, a subscriber that outlives cleanup, and reads * that wait for readiness. */ @@ -64,7 +64,13 @@ import { resumeDeferredLiveQueryCollections } from '../src/live-query-options.js import { Query } from '../src/query/builder/index.js' import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' import { expectedStartHandoff } from './collection-subscription-lifecycle-grammar-oracle.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { createOracleReach } from './oracle-reach.js' import { declareOracle } from './oracle-declaration.js' import { @@ -418,6 +424,7 @@ describe(`live-query deferred acquisition`, () => { for (const depth of [1, 2] as const) { for (const [name, commands] of Object.entries(HISTORIES)) { it(`${state}, ${shape}, depth ${depth}: ${name}`, async () => { + oracleRoute(`case`) return runAcquisitionHistory(state, shape, depth, commands) }) } @@ -441,7 +448,15 @@ describe(`live-query deferred acquisition`, () => { * client, which must not mistake the absent config for a live-query builder. */ // The pooled entry point takes the internal builder, as its own oracle does. +// What this oracle owns: the gap report judges the deferred sync-start and resumption +// entry points and pooled live-query creation by this file's histories. declareOracle({ + owns: [ + `src/live-query-options.ts#resumeDeferredLiveQueryCollections`, + `src/query/pooled-live-query.ts#createPooledLiveQuery`, + `src/collection/index.ts#CollectionImpl._deferSyncStart`, + `src/collection/index.ts#CollectionImpl._resumeSyncStart`, + ], check: [runAcquisitionHistory, runPooledHistory, runDeferredSiblingHistory], model: [ initialModel, @@ -518,6 +533,7 @@ describe(`pooled live-query deferred acquisition`, () => { for (const state of [`eager-idle`, `eager-running`] as const) { for (const [name, commands] of Object.entries(pooledHistories)) { it(`${state}: ${name}`, async () => { + oracleRoute(`case`) return runPooledHistory(state, commands) }) } @@ -583,6 +599,7 @@ describe(`a subscriber that outlives cleanup`, () => { cleanupAt, restartFromCleanup, }) => { + oracleRoute(`case`) const expected = expectedReplacementRun(state, reader) const { collection: source, counts } = makeSource(state) const { outer, collections } = makeLiveQuery(source, shape, depth) @@ -675,6 +692,11 @@ describe(`render sync-start handoff error provenance`, () => { `undefined-cause`, `healthy`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(100) }) + : oraclePropertyOptions(100, `live-query.start-handoff`) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -747,9 +769,7 @@ describe(`render sync-start handoff error provenance`, () => { } }, ), - campaign === `fixed` - ? { numRuns: oracleRuns(100), seed: 2229 } - : oraclePropertyOptions(100, `live-query.start-handoff`), + parameters, ) reach.assertReached() console.info( @@ -921,6 +941,8 @@ async function runDeferredSiblingHistory(history: DeferredSiblingHistory) { } describe(`deferred demand joining a sibling replay`, () => { for (const history of deferredSiblingHistories) - it(`finishes after resumption: ${JSON.stringify(history)}`, () => - runDeferredSiblingHistory(history)) + it(`finishes after resumption: ${JSON.stringify(history)}`, () => { + oracleRoute(`case`) + return runDeferredSiblingHistory(history) + }) }) diff --git a/packages/db/tests/live-query-deferred-acquisition.test.ts b/packages/db/tests/live-query-deferred-acquisition.test.ts index 41207ec428..066c9255a0 100644 --- a/packages/db/tests/live-query-deferred-acquisition.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition.test.ts @@ -635,7 +635,7 @@ describe(`preload answered by a DbClient stream`, () => { * A subscriber that survives is still a subscriber in the next sync run, so a * restarted live-query Collection acquires for it at once instead of * deferring until another subscriber or preload arrives. The finite command - * histories above unsubscribe before cleanup. This block pins the history: subscribe, + * histories above unsubscribe before cleanup. This block covers the history: subscribe, * clean up while subscribed, restart, then settle. */ describe(`a subscriber that outlives cleanup`, () => { diff --git a/packages/db/tests/live-query-observer-history-oracle.property.test.ts b/packages/db/tests/live-query-observer-history-oracle.property.test.ts index 2e664fee4d..0bb0633c2a 100644 --- a/packages/db/tests/live-query-observer-history-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-history-oracle.property.test.ts @@ -3,7 +3,13 @@ import { describe, expect, it, vi } from 'vitest' import { DbClient } from '../src/client.js' import { createCollection } from '../src/collection/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { finishHistory } from './observer-history-cleanup.js' import { withOracleCleanup } from './utils.js' @@ -54,6 +60,14 @@ describe(`observer hydration error timing`, () => { `defined-cause`, `undefined-cause`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(64) }) + : oraclePropertyOptions( + 64, + `live-query-observer.hydration-error-timing`, + ) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(historyArbitrary, async (history) => { reach.beginHistory() @@ -133,12 +147,7 @@ describe(`observer hydration error timing`, () => { () => client.cleanup(), ]) }), - campaign === `fixed` - ? { numRuns: oracleRuns(64), seed: 2229 } - : oraclePropertyOptions( - 64, - `live-query-observer.hydration-error-timing`, - ), + parameters, ) reach.assertReached() console.info( @@ -169,6 +178,11 @@ describe(`observer release failure ownership`, () => { `retry`, `undefined-cause`, ]) + const campaignParameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(40) }) + : oraclePropertyOptions(40, `live-query-observer.release-failure`) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -268,9 +282,7 @@ describe(`observer release failure ownership`, () => { ]) }, ), - campaign === `fixed` - ? { numRuns: oracleRuns(40), seed: 2229 } - : oraclePropertyOptions(40, `live-query-observer.release-failure`), + campaignParameters, ) reach.assertReached() console.info( @@ -299,6 +311,11 @@ describe(`observer partial attachment`, () => { `surviving-peer`, `no-peer`, ]) + const runParameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(60) }) + : oraclePropertyOptions(60, `live-query-observer.partial-attach`) + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -432,9 +449,7 @@ describe(`observer partial attachment`, () => { ]) }, ), - campaign === `fixed` - ? { numRuns: oracleRuns(60), seed: 2229 } - : oraclePropertyOptions(60, `live-query-observer.partial-attach`), + runParameters, ) reach.assertReached() console.info( @@ -1027,7 +1042,11 @@ function reactionHistory(reaction: Reaction): Array { ] } +// What this oracle owns: the gap report judges every function in +// src/live-query-observer.ts by this file's generated and fixed histories of +// subscribe, publish, markError, and dispose. declareOracle({ + owns: [`src/live-query-observer.ts`], check: [runHistory, assertHistory], model: [subscribeModel, unsubscribeModel, publishModel, disposeModel], grammar: [command, listener], @@ -1038,6 +1057,8 @@ describe(`LiveQueryObserver generated histories`, () => { it.each([1813, -1359260562])( `${mode} listeners follow the eligibility ledger with fixed seed=%s`, async (seed) => { + const parameters = oracleFixedSeed(seed, { numRuns: oracleRuns(75) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array(command, { minLength: 1, maxLength: 30 }), @@ -1045,11 +1066,13 @@ describe(`LiveQueryObserver generated histories`, () => { await runHistory(mode, history) }, ), - { numRuns: oracleRuns(75), seed }, + parameters, ) }, ) it(`${mode} listeners follow the eligibility ledger with a random or replayed seed`, async () => { + const parameters = oraclePropertyOptions(75, historyProperties[mode]) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array(command, { minLength: 1, maxLength: 30 }), @@ -1057,7 +1080,7 @@ describe(`LiveQueryObserver generated histories`, () => { await runHistory(mode, history) }, ), - oraclePropertyOptions(75, historyProperties[mode]), + parameters, ) }) } @@ -1065,6 +1088,7 @@ describe(`LiveQueryObserver generated histories`, () => { it.each([`granular`, `wholesale`] as const)( `%s listeners joining during dispatch start with the next eligible publication`, async (mode) => { + oracleRoute(`fixed history`) const history: Array = [ { type: `subscribe`, listener: 0 }, { @@ -1094,6 +1118,7 @@ describe(`LiveQueryObserver generated histories`, () => { ) it(`executes every command and reaction kind in fixed production histories`, async () => { + oracleRoute(`fixed history`) const reach = emptyReach() for (const mode of [`granular`, `wholesale`] as const) { await runHistory( @@ -1119,6 +1144,7 @@ describe(`LiveQueryObserver generated histories`, () => { it.each([`granular`, `wholesale`] as const)( `%s one-row version margins retain the no-change boundary`, async (mode) => { + oracleRoute(`fixed history`) await runHistory(mode, [ { type: `publish`, @@ -1152,6 +1178,7 @@ describe(`LiveQueryObserver generated histories`, () => { it.each([`granular`, `wholesale`] as const)( `rejects dropped peer delivery after a throwing listener through the %s production path`, async (mode) => { + oracleRoute(`calibration`) const history = reactionHistory(`throw`) await runHistory(mode, history) const reach = emptyReach() @@ -1163,6 +1190,7 @@ describe(`LiveQueryObserver generated histories`, () => { ) it(`rejects a duplicate granular bootstrap through the production seed path`, async () => { + oracleRoute(`calibration`) const history: Array = [ { type: `publish`, @@ -1183,6 +1211,7 @@ describe(`LiveQueryObserver generated histories`, () => { it.each([`granular`, `wholesale`] as const)( `rejects a reentrant publication delivered out of FIFO order through the %s production path`, async (mode) => { + oracleRoute(`calibration`) const history = reactionHistory(`nested`) await runHistory(mode, history) const reach = emptyReach() diff --git a/packages/db/tests/live-query-observer-history.test.ts b/packages/db/tests/live-query-observer-history.test.ts index 7f766a9466..10028b5877 100644 --- a/packages/db/tests/live-query-observer-history.test.ts +++ b/packages/db/tests/live-query-observer-history.test.ts @@ -72,7 +72,7 @@ describe(`LiveQueryObserver generated histories`, () => { /** * Failed subscription setup transfers no listener ownership to the observer: - * the caller received no release handle. These fixed cases pin each failure + * the caller received no release handle. These fixed cases check each failure * point, cleanup rejection and a real sync startup throw, in both modes, with * one or two failed attempts before an explicit retry. They compare with * written lease counts. The generated ownership law that these cases illustrate diff --git a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts index f3f2ab9aba..9a240edda9 100644 --- a/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-hydration-oracle.property.test.ts @@ -1,14 +1,16 @@ -import { fc } from '@fast-check/vitest' -import { afterAll, describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' +import fc from 'fast-check' import { DbClient } from '../src/client.js' import { createCollection } from '../src/collection/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' import { oraclePropertyOptions, oracleReplayTest, + oracleRoute, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' +import { oracleProperty, pass, violation } from './oracle-property.js' import { createOracleReach } from './oracle-reach.js' import { ScenarioLifetime } from './conformance/scenario-lifetime.js' import { @@ -55,6 +57,22 @@ import type { * and a cleaned-up Collection restarts on demand. While the seed is active, * cleanup and a restart are part of "not produced yet": the snapshot keeps * showing the seed as `ready`, and the next sync run's readiness hands off. + * Once the seed has ended, the observer shows the live Collection through + * cleanup and restart too: `cleaned-up` with no rows (cleanup discards all + * of the Collection's state, glossary "crash"), then each row the new sync + * run writes. That includes a row equal to one shown before cleanup: the + * observer let that row go at cleanup, so a Collection subscription that + * reconciles later writes against the rows it published before cleanup + * must not count it as already shown ([#2334](https://github.com/TanStack/db/issues/2334)). + * This claim adds no rule of its own. Its authority is law 2 (an observer + * whose seed ended behaves like one without a seed, which shows the live + * Collection) and the glossary: "cleanup" ends the sync run and leaves the + * Collection available for restart, and "crash" says that cleanup discards + * all of the Collection's state, so a later start is a fresh start. A row + * the new sync run writes is a row the live Collection holds, whether or + * not an equal row was shown before. ARCHITECTURE "Cleanup, restart, and + * detached waiters" documents the mechanism that keeps it: the observer's + * Collection subscription retains no rows across cleanup. * 4. **Every visible change is announced.** Wholesale consumers re-read * `getSnapshot()` only when notified (`useSyncExternalStore` does one * consistency read right after subscribing). So, while attached, the @@ -65,23 +83,37 @@ import type { * * The model is the seed, whether an attached observer has ended it, and * whether the observer is attached. The Collection is the observer's input: - * the driver reads its public status and rows and does not model the - * Collection lifecycle. Rows under a failure status are not claimed, because - * that status says the data cannot be shown; law 4 still checks them. + * the driver records its public status and rows as the step's input, and the + * model reads only those, so it does not model the Collection lifecycle. The + * observer's snapshot and announcements are the step's output, which only the + * check reads. Rows under a failure status are not claimed, because that + * status says the data cannot be shown; law 4 still checks them. * * ## Contract and ownership table | Contract | History/domain | Production path | Observation and checkpoint | Owner and limits | | --- | --- | --- | --- | --- | -| failure shown under a seed | seed of 0..3 rows; live writes; `fail()`, `crash()`, cleanup, restart, `markReady()`, attach, in any legal order | DbClient hydrate -> getSnapshot status mapping; status listener -> notify | snapshot `status` and flags after each step and a macrotask | this generated oracle | +| failure shown under a seed | seed of 0..3 rows, or none; live writes; `fail()`, `crash()`, cleanup, restart, `markReady()`, attach, in any legal order | DbClient hydrate -> getSnapshot status mapping; status listener -> notify | snapshot `status` and flags after each step and a macrotask | this generated oracle | | failure ends the seed | the same, with cleanup after the failure | notify/attach -> seed handoff | `cleaned-up` and live rows after cleanup | this generated oracle | | cleanup keeps the seed | cleanup and restart while the seed is active | notify early return under the seed | `ready` and seed rows | this generated oracle; recorded decision, not a law-16 claim | +| restart after the seed ended | cleanup and restart after readiness or a failure ended the seed; the new sync run rewrites a row equal to one shown before cleanup | notify `cleaned-up` reset -> Collection subscription reconciliation | live rows after each later step | this generated oracle | | announced changes | every step while attached | emit -> flushPublications | last-read snapshot and granular rebuilt rows equal the current snapshot | this generated oracle | | already-ready subset handoff | held subset at attach, then settlement without row/status change; both modes | seeded observer -> subset settlement | seed while held, live snapshot and announced granular/wholesale rows after settlement | generated ready-subset lane below; Svelte page receiver | | other handoff timing, streamed errors, consumption | | | | focused `live-query-observer.test.ts` hydration tests | - * Keys are `a`..`c`, versions 0..3, histories of 1..10 generated steps after - * an optional fixed three-step prefix, both modes. Rows are + * Keys are `a`..`c`, versions 0..3, histories of 1..10 steps, both modes, + * and an eager or an on-demand source. Every sync write also lands in the + * provider's rows, which survive cleanup, and the on-demand source's loader + * writes all of them again whenever a subscription loads a subset. So an + * on-demand restart reacquires the subscription's demand and re-delivers rows + * the observer showed before cleanup, which the restart claim of law 3 must + * survive. A fifth of the histories hydrate no seed and build the observer + * without a DbClient. Law 2 says an observer whose seed ended behaves like + * that one, so it is the reference case, and it is the only observer whose + * granular subscription asks the source for its initial rows: the subset + * demand that an on-demand restart reacquires. + * Constructed histories start a generated history with the steps that enter each declared + * state, so every grammar reaches every state. Rows are * compared as sets by key: this oracle makes no order claim. The source is a * plain controlled Collection, not a live-query Collection, so source health * propagation through a query graph belongs to `source-health-oracle.test.ts`. @@ -89,6 +121,7 @@ import type { type Row = { id: string; version: number } type Mode = `granular` | `wholesale` +type SyncMode = `eager` | `on-demand` type Key = `a` | `b` | `c` type Event = | { type: `write`; key: Key; version: number | null } @@ -100,29 +133,26 @@ type Event = | { type: `attach` } type History = { mode: Mode - seed: Array<[Key, number]> + syncMode: SyncMode + /** The hydrated server rows, or `null` for an observer with no DbClient. */ + seed: Array<[Key, number]> | null events: Array } type VisibleRow = [string, number] type View = { status: CollectionStatus | `disabled`; rows: Array } -const reachStates = [ - `ready-under-seed:attached`, - `error-under-seed:attached`, - `crashed-under-seed:attached`, - `cleaned-up-under-seed:attached`, - `ready-under-seed:detached`, - `error-under-seed:detached`, - `crashed-under-seed:detached`, - `cleaned-up-under-seed:detached`, - `attach-to-failure-under-seed`, - `cleanup-after-failure-ended-seed`, - `restart-under-seed`, -] as const -type ReachState = (typeof reachStates)[number] +// --------------------------------------------------------------------------- +// Histories +// --------------------------------------------------------------------------- +const MAX_EVENTS = 10 const key = fc.constantFrom(`a`, `b`, `c`) const version = fc.integer({ min: 0, max: 3 }) +// A record of constants, not `fc.constant(object)`, so that a fixed or constructed history +// built from a literal is a member of the language (fast-check compares a +// constant by identity). +const step = (type: Type) => + fc.record({ type: fc.constant(type) }) const event: fc.Arbitrary = fc.oneof( { arbitrary: fc.record({ @@ -132,26 +162,115 @@ const event: fc.Arbitrary = fc.oneof( }), weight: 2, }, - { arbitrary: fc.constant({ type: `ready` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `fail` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `crash` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `cleanup` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `restart` as const }), weight: 1 }, - { arbitrary: fc.constant({ type: `attach` as const }), weight: 2 }, + { arbitrary: step(`ready`), weight: 1 }, + { arbitrary: step(`fail`), weight: 1 }, + { arbitrary: step(`crash`), weight: 1 }, + { arbitrary: step(`cleanup`), weight: 1 }, + { arbitrary: step(`restart`), weight: 1 }, + { arbitrary: step(`attach`), weight: 2 }, ) const history: fc.Arbitrary = fc.record({ mode: fc.constantFrom(`granular`, `wholesale`), - seed: fc.uniqueArray(fc.tuple(key, version), { - selector: ([id]) => id, - maxLength: 3, - }), - events: fc.array(event, { minLength: 1, maxLength: 10 }), + syncMode: fc.constantFrom(`eager`, `on-demand`), + seed: fc.option( + fc.uniqueArray(fc.tuple(key, version), { + selector: ([id]) => id, + maxLength: 3, + }), + { nil: null, freq: 4 }, + ), + events: fc.array(event, { minLength: 1, maxLength: MAX_EVENTS }), }) +/** A step written as its type alone, or as a whole event (a write). */ +type StepSpec = Event[`type`] | Event +const toEvent = (spec: StepSpec): Event => + typeof spec === `string` ? ({ type: spec } as Event) : spec + +/** Starts a generated history with `prefix`, keeping it inside the grammar. */ +const startWith = + (...prefix: Array) => + (generated: History): History => ({ + ...generated, + events: [...prefix.map(toEvent), ...generated.events].slice(0, MAX_EVENTS), + }) + +/** `startWith` for a state under the seed: an unseeded history gets an empty seed. */ +const startSeededWith = + (...prefix: Array) => + (generated: History): History => + startWith(...prefix)({ ...generated, seed: generated.seed ?? [] }) + +/** + * Starts a generated history by ending the seed with `end`, writing a row, + * cleaning up, restarting, and writing the same row again. The row is the + * generated history's first upsert, so the repeated key and version vary. + * `plan` fixes the dimensions a declared state needs. + */ +const rewriteAfterRestart = + (end: `ready` | `fail`, plan: Partial = {}) => + (generated: History): History => { + const row = generated.events.find( + (next) => next.type === `write` && next.version !== null, + ) ?? { type: `write`, key: `a`, version: 0 } + const prefix: Array = + end === `ready` + ? [`attach`, `ready`, row, `cleanup`, `restart`, row] + : [`attach`, row, `fail`, `cleanup`, `restart`, row] + return startWith(...prefix)({ ...generated, ...plan }) + } + +// --------------------------------------------------------------------------- +// Trace +// --------------------------------------------------------------------------- +// +// One record per checkpoint: `start`, then one per step. A skipped step (one +// that is illegal in the Collection's current status) records only that it +// was skipped. An executed step records its input, the Collection's status +// before the step, right after it, and after a macrotask, and its rows then; +// and its output, what the observer showed and announced. + +type StepInput = { + action: Event[`type`] | `start` + before: CollectionStatus + during: CollectionStatus + after: CollectionStatus + live: Array +} +type StepOutput = { + view: View + flags: Record< + `isReady` | `isLoadingError` | `isCrashed` | `isCleanedUp`, + boolean + > + /** The snapshot a listener last read; undefined before attach. */ + lastRead: View | undefined + /** A granular listener's rows rebuilt from delivered changes. */ + rebuilt: Array | undefined +} +type StepRecord = { skipped: true } | { input: StepInput; output: StepOutput } + // --------------------------------------------------------------------------- // Model // --------------------------------------------------------------------------- +const reachStates = [ + `ready-under-seed:attached`, + `error-under-seed:attached`, + `crashed-under-seed:attached`, + `cleaned-up-under-seed:attached`, + `ready-under-seed:detached`, + `error-under-seed:detached`, + `crashed-under-seed:detached`, + `cleaned-up-under-seed:detached`, + `attach-to-failure-under-seed`, + `cleanup-after-failure-ended-seed`, + `restart-under-seed`, + `restart-rewrites-row-from-before-cleanup`, + `restart-reacquires-row-from-before-cleanup`, +] as const +type ReachState = (typeof reachStates)[number] + /** * `ended` records law 2: an attached observer saw a status that ends the * seed, either initial-query readiness (the handoff) or a failure. @@ -164,6 +283,12 @@ type Model = { } const failures: ReadonlySet = new Set([`error`, `crashed`]) +const shownUnderSeed: ReadonlySet = new Set([ + `ready`, + `error`, + `crashed`, + `cleaned-up`, +]) function seedActive(model: Model): boolean { return !model.ended @@ -171,7 +296,7 @@ function seedActive(model: Model): boolean { /** * Advance the model across one step. `statuses` are the Collection statuses - * before and after the step. An attached observer ends the seed when it sees + * the observer could see. An attached observer ends the seed when it sees * readiness or a failure (laws 1 and 2); cleanup and restart do not (law 3). */ function advance(model: Model, statuses: ReadonlyArray): void { @@ -195,9 +320,87 @@ function expectedView( return { status: liveStatus, rows: liveRows } } -// The model predicts the view; the grammar draws the histories that the -// driver below replays, including the fixed replays at the end of the file. -declareOracle({ model: [advance, expectedView], grammar: [history] }) +type Expectation = ReturnType & { attached: boolean } + +/** + * Folds the trace's inputs through the model: the view owed at each + * checkpoint, and the declared states the history entered, which are model + * states (whether the seed is active), so the model names them. + */ +function hydrationModel( + { seed, mode, syncMode }: History, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, +): { owed: Map; reached: Set } { + const model: Model = { + seed: sortedRows( + (seed ?? []).map(([id, value]) => [id, { id, version: value }]), + ), + // An observer without a seed is the one that law 2's ended seed + // behaves like. + ended: seed === null, + endedByFailure: false, + attached: false, + } + const owed = new Map() + const reached = new Set() + // The rows the Collection held right before the latest cleanup, and + // whether a sync run has restarted since. A step of the new run that + // brings one of those rows back is where law 3's restart claim matters + // after the seed ended. On an on-demand source, an unseeded granular + // observer's own subset demand brings it back when the restart reacquires + // that demand. Rows are compared by value as JSON. + // The two restart states owe nothing new: `expectedView` already owes the + // live rows once the seed ended (law 2), and the glossary's "cleanup" and + // "crash" make the restarted run a fresh start. The states only mark the + // histories where that law meets a row equal to one from before cleanup. + let previousLive: Array = [] + let heldBeforeCleanup = new Set() + let restarted = false + for (const { checkpoint, value } of trace) { + const record = value as StepRecord + if (`skipped` in record) continue + const { action, before, during, after, live } = record.input + if (action === `cleanup` && model.endedByFailure && failures.has(before)) + reached.add(`cleanup-after-failure-ended-seed`) + if (action === `restart` && seedActive(model)) + reached.add(`restart-under-seed`) + if (action === `cleanup`) { + heldBeforeCleanup = new Set( + previousLive.map((row) => JSON.stringify(row)), + ) + restarted = false + } + if (action === `restart`) restarted = true + if ( + (action === `write` || action === `restart`) && + restarted && + model.attached && + !seedActive(model) && + live.some((row) => heldBeforeCleanup.has(JSON.stringify(row))) + ) { + reached.add(`restart-rewrites-row-from-before-cleanup`) + if (seed === null && mode === `granular` && syncMode === `on-demand`) + reached.add(`restart-reacquires-row-from-before-cleanup`) + } + previousLive = live + if (action === `attach`) { + if (seedActive(model) && failures.has(before)) + reached.add(`attach-to-failure-under-seed`) + model.attached = true + } + if (seedActive(model) && before !== during && shownUnderSeed.has(during)) + reached.add( + `${during}-under-seed:${model.attached ? `attached` : `detached`}` as ReachState, + ) + advance(model, [before, during]) + advance(model, [after]) + owed.set(checkpoint, { + ...expectedView(model, after, live), + attached: model.attached, + }) + } + return { owed, reached } +} // --------------------------------------------------------------------------- // Production driver @@ -216,62 +419,69 @@ function settle(): Promise { let sequence = 0 /** - * Runs one history. Steps that are illegal in the Collection's current - * status, such as `markReady()` outside `loading`, are skipped, so every - * executed step is a legal source history. + * Runs one history and records each checkpoint. Steps that are illegal in + * the Collection's current status, such as `markReady()` outside `loading`, + * are skipped, so every executed step is a legal source history. Attach + * subscribes once and does the consistency read `useSyncExternalStore` does. */ -async function runHistory( - { mode, seed, events }: History, - reach?: ReturnType>, +async function driveHistory( + { mode, syncMode, seed, events }: History, + observe: (checkpoint: string, value: StepRecord) => void, ): Promise { const id = `observer-hydration-${sequence++}` const queryHash = `${id}-query` let ops: Parameters[`sync`]>[0] | undefined + const provider = new Map() const source = createCollection({ id, getKey: (row) => row.id, startSync: false, + syncMode, sync: { sync: (params) => { ops = params + if (syncMode === `eager`) return + // A subset load re-delivers every provider row and settles at once. + return { + loadSubset: () => { + params.begin() + for (const value of provider.values()) + params.write({ + type: source.has(value.id) ? `update` : `insert`, + value, + }) + params.commit() + return true + }, + } }, }, }) const client = new DbClient() - client.hydrate({ - collections: [], - liveQueries: [ - { - queryHash, - dehydratedAt: 1, - snapshot: { - rows: seed.map(([rowKey, rowVersion]) => ({ - key: rowKey, - value: { id: rowKey, version: rowVersion }, - })), + if (seed) + client.hydrate({ + collections: [], + liveQueries: [ + { + queryHash, + dehydratedAt: 1, + snapshot: { + rows: seed.map(([rowKey, rowVersion]) => ({ + key: rowKey, + value: { id: rowKey, version: rowVersion }, + })), + }, }, - }, - ], - }) + ], + }) source.startSyncImmediate() - const observer = createLiveQueryObserver(source, { - client, - queryHash, - mode, - }) + const observer = createLiveQueryObserver( + source, + seed ? { client, queryHash, mode } : { mode }, + ) - const model: Model = { - seed: sortedRows( - seed.map(([rowKey, rowVersion]) => [ - rowKey, - { id: rowKey, version: rowVersion }, - ]), - ), - ended: false, - endedByFailure: false, - attached: false, - } const rebuilt = new Map() + let attached = false let lastRead: View | undefined const read = (): View => { const snapshot = observer.getSnapshot() @@ -296,110 +506,106 @@ async function runHistory( } lastRead = read() } - - const check = (step: number) => { - const context = JSON.stringify({ mode, seed, events, step }) + const record = ( + checkpoint: string, + input: Omit, + ) => { const snapshot = observer.getSnapshot() - const view = read() - const expected = expectedView( - model, - source.status, - sortedRows(source.entries()), - ) - expect(view.status, context).toBe(expected.status) - expect( - { - isReady: snapshot.isReady, - isLoadingError: snapshot.isLoadingError, - isCrashed: snapshot.isCrashed, - isCleanedUp: snapshot.isCleanedUp, + observe(checkpoint, { + input: { + ...input, + after: source.status, + live: sortedRows(source.entries()), + }, + output: { + view: read(), + flags: { + isReady: snapshot.isReady, + isLoadingError: snapshot.isLoadingError, + isCrashed: snapshot.isCrashed, + isCleanedUp: snapshot.isCleanedUp, + }, + lastRead, + rebuilt: + attached && mode === `granular` ? sortedRows(rebuilt) : undefined, }, - context, - ).toEqual({ - isReady: expected.status === `ready`, - isLoadingError: expected.status === `error`, - isCrashed: expected.status === `crashed`, - isCleanedUp: expected.status === `cleaned-up`, }) - if (expected.rows) expect(view.rows, context).toEqual(expected.rows) - if (model.attached) { - expect(lastRead, `${context} (unannounced change)`).toEqual(view) - if (mode === `granular`) { - expect(sortedRows(rebuilt), `${context} (granular rows)`).toEqual( - view.rows, - ) - } - } - } - - const note = (state: ReachState) => reach?.reach(state) - const noteStatusUnderSeed = (before: string, after: string) => { - if (!seedActive(model) || before === after) return - if ( - after === `ready` || - after === `error` || - after === `crashed` || - after === `cleaned-up` - ) { - note(`${after}-under-seed:${model.attached ? `attached` : `detached`}`) - } } let failure: { error: unknown } | undefined try { - check(-1) - for (const [step, next] of events.entries()) { + record(`start`, { + action: `start`, + before: source.status, + during: source.status, + }) + for (const [index, next] of events.entries()) { + const checkpoint = `step ${index} (${next.type})` const before = source.status + const skip = () => observe(checkpoint, { skipped: true }) if (next.type === `write`) { - if (before !== `loading` && before !== `ready`) continue + if (before !== `loading` && before !== `ready`) { + skip() + continue + } const present = source.has(next.key) - if (next.version === null && !present) continue + if (next.version === null && !present) { + skip() + continue + } + const value = { id: next.key, version: next.version ?? 0 } + if (next.version === null) provider.delete(next.key) + else provider.set(next.key, value) ops!.begin() ops!.write( next.version === null ? { type: `delete`, key: next.key } - : { - type: present ? `update` : `insert`, - value: { id: next.key, version: next.version }, - }, + : { type: present ? `update` : `insert`, value }, ) ops!.commit() } else if (next.type === `ready`) { - if (before !== `loading`) continue + if (before !== `loading`) { + skip() + continue + } ops!.markReady() } else if (next.type === `fail`) { - if (before !== `loading`) continue + if (before !== `loading`) { + skip() + continue + } ops!.fail(new Error(`first load failed`)) } else if (next.type === `crash`) { if (before !== `loading` && before !== `ready` && before !== `error`) { + skip() continue } source._lifecycle.crash(new Error(`local storage closed`)) } else if (next.type === `cleanup`) { - if (before === `cleaned-up`) continue - if (model.endedByFailure && failures.has(before)) { - note(`cleanup-after-failure-ended-seed`) + if (before === `cleaned-up`) { + skip() + continue } await source.cleanup() } else if (next.type === `restart`) { - if (before !== `cleaned-up`) continue - if (seedActive(model)) note(`restart-under-seed`) + if (before !== `cleaned-up`) { + skip() + continue + } source.startSyncImmediate() } else { - if (model.attached) continue - if (seedActive(model) && failures.has(before)) { - note(`attach-to-failure-under-seed`) + if (attached) { + skip() + continue } - model.attached = true + attached = true observer.subscribe(listener) // The consistency read useSyncExternalStore does after subscribing. lastRead = read() } - noteStatusUnderSeed(before, source.status) - advance(model, [before, source.status]) + const during = source.status await settle() - advance(model, [source.status]) - check(step) + record(checkpoint, { action: next.type, before, during }) } } catch (error) { failure = { error } @@ -410,84 +616,303 @@ async function runHistory( } // --------------------------------------------------------------------------- -// Properties +// Check // --------------------------------------------------------------------------- +// +// At every executed checkpoint the snapshot's status and flags are the owed +// status, and its rows are the owed rows when the model claims them (law +// `view`: laws 1-3). While attached, the snapshot a listener last read and a +// granular listener's rebuilt rows equal the current snapshot (law +// `announcement`: law 4). The trace must cover the start and every step; that +// is judged last, so a driver error cannot hide an earlier violation. -/** - * Fixed prefixes put each declared state within reach of every run budget; - * the generated suffix explores what follows. - */ -const prefixes: ReadonlyArray> = [ - [{ type: `attach` }, { type: `crash` }, { type: `cleanup` }], - [{ type: `attach` }, { type: `fail` }, { type: `cleanup` }], - [{ type: `crash` }, { type: `attach` }, { type: `cleanup` }], - [{ type: `attach` }, { type: `cleanup` }, { type: `restart` }], - [{ type: `cleanup` }, { type: `attach` }, { type: `ready` }], +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) + +function checkHistory( + { events }: History, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + { owed }: ReturnType, +) { + for (const { checkpoint, value } of trace) { + const record = value as StepRecord + if (`skipped` in record) continue + const { view, flags, lastRead, rebuilt } = record.output + const expected = owed.get(checkpoint)! + if (view.status !== expected.status) + return violation( + checkpoint, + `status ${view.status}, owed ${expected.status}`, + `view`, + { actual: view.status, expected: expected.status }, + ) + const owedFlags = { + isReady: expected.status === `ready`, + isLoadingError: expected.status === `error`, + isCrashed: expected.status === `crashed`, + isCleanedUp: expected.status === `cleaned-up`, + } + if (!same(flags, owedFlags)) + return violation( + checkpoint, + `flags ${JSON.stringify(flags)}, owed ${JSON.stringify(owedFlags)}`, + `view`, + { actual: flags, expected: owedFlags }, + ) + if (expected.rows !== undefined && !same(view.rows, expected.rows)) + return violation( + checkpoint, + `rows ${JSON.stringify(view.rows)}, owed ${JSON.stringify(expected.rows)}`, + `view`, + { actual: view.rows, expected: expected.rows }, + ) + if (!expected.attached) continue + if (!same(lastRead, view)) + return violation( + checkpoint, + `a listener last read ${JSON.stringify(lastRead)}; the snapshot is ${JSON.stringify(view)} (unannounced change)`, + `announcement`, + { actual: lastRead, expected: view }, + ) + if (rebuilt !== undefined && !same(rebuilt, view.rows)) + return violation( + checkpoint, + `granular rows ${JSON.stringify(rebuilt)}; the snapshot has ${JSON.stringify(view.rows)}`, + `announcement`, + { actual: rebuilt, expected: view.rows }, + ) + } + // Completeness last: a history whose driver threw keeps the violation + // found at a recorded checkpoint. + if (trace.length !== events.length + 1) + return violation( + `trace`, + `the driver recorded ${trace.length} checkpoints for ${events.length} steps`, + `trace`, + ) + return pass +} + +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +// Replays of the reported trace and its neighbors, in both modes. Each one +// failed before the fix (see the coverage map entry for this owner). +const rowA: Event = { type: `write`, key: `a`, version: 1 } +const replays: ReadonlyArray< + [name: string, events: Array, unseeded?: `also unseeded`] +> = [ + [ + `shows and announces a crash under an active seed`, + [`attach`, `crash`, `cleanup`], + ], + [ + `shows cleanup after a first-load failure instead of the stale error`, + [`attach`, `fail`, `cleanup`], + ], + [ + `shows a crash that happened before it attached`, + [`crash`, `attach`, `cleanup`], + ], + // #2334 and the random campaign's seed 1779366423: the restarted sync run + // writes the row the observer showed before cleanup. The trailing `ready` + // judges the same rows again at a ready checkpoint. + [ + `shows a row the restarted sync run rewrites after readiness ended the seed`, + [`attach`, `ready`, rowA, `cleanup`, `restart`, rowA, `ready`], + `also unseeded`, + ], + [ + `shows a row the restarted sync run rewrites after a failure ended the seed`, + [`attach`, rowA, `fail`, `cleanup`, `restart`, rowA, `ready`], + `also unseeded`, + ], ] -const prefixedHistory: fc.Arbitrary = fc - .tuple(history, fc.option(fc.constantFrom(...prefixes), { freq: 2 })) - .map(([generated, prefix]) => ({ - ...generated, - events: [...(prefix ?? []), ...generated.events], - })) -describe(`LiveQueryObserver hydration seed under terminal statuses`, () => { - const reach = createOracleReach( - `live-query-observer.hydration-terminal`, - reachStates, - ) - let historiesRan = false - afterAll(() => { - if (historiesRan) reach.assertReached() - }) +// What this oracle owns: the gap report judges every function in +// src/live-query-observer.ts and DbClient.hydrate by this file's generated +// hydration-terminal histories, which end a seed's sync run in every way. It +// also judges CollectionSubscription.handleCollectionCleanup, where the +// observer's `discardsRowsOnCleanup` drops the rows it showed before cleanup +// (law 3's restart claim, #2334). The two kinds of entry differ. A whole-file +// entry owns the functions defined in that file and nothing it refers to in +// another file, so `src/live-query-observer.ts` alone never reached the +// subscription. A `#function` entry owns that function's text, every function +// defined inside it, and its static reference tree within the package: each +// package function, method, or class its text refers to, then what those refer +// to in turn, stopping at the package boundary (owners.mjs in @tanstack/oracles, +// maintainer decisions 2026-11-05 and 2026-11-08). So this entry owns, through +// that walk, the functions it reaches, including +// CollectionSubscription.discardTruncateReplay, +// CollectionSubscription.setReadyIfIdle, and cancelAcquisition. No Collection +// subscription owner subscribes with the option. - it.each([20260, -88123])( - `generated histories refine the hydration model with fixed seed=%s`, - async (seed) => { - await fc.assert( - fc.asyncProperty(prefixedHistory, async (generated) => { - historiesRan = true - reach.beginHistory() - await runHistory(generated, reach) - }), - { numRuns: oracleRuns(100), seed }, - ) - }, - 60_000, - ) +declareOracle({ + owns: [ + `src/live-query-observer.ts`, + `src/client.ts#DbClient.hydrate`, + `src/collection/subscription.ts#CollectionSubscription.handleCollectionCleanup`, + ], +}) - it(`generated histories refine the hydration model with a random or replayed seed`, async () => { - await fc.assert( - fc.asyncProperty(prefixedHistory, async (generated) => { - historiesRan = true - reach.beginHistory() - await runHistory(generated, reach) - }), - oraclePropertyOptions(100), - ) - }, 60_000) - - // Replays of the reported trace and its neighbors, in both modes. Each one - // failed before the fix (see the coverage map entry for this owner). - const replays: ReadonlyArray<[string, Array]> = [ - [ - `shows and announces a crash under an active seed`, - [{ type: `attach` }, { type: `crash` }, { type: `cleanup` }], - ], - [ - `shows cleanup after a first-load failure instead of the stale error`, - [{ type: `attach` }, { type: `fail` }, { type: `cleanup` }], - ], - [ - `shows a crash that happened before it attached`, - [{ type: `crash` }, { type: `attach` }, { type: `cleanup` }], +oracleProperty({ + id: `live-query-observer.hydration-terminal`, + law: { + statement: `While a hydration seed is active, the observer shows a Collection error or crashed status, attached or detached; an attached observer that sees a failure ends the seed, so a later cleanup shows the live Collection; cleanup and restart alone keep the seed as ready; after the seed ends, cleanup and restart show the live Collection, including a row the new sync run writes again; and every change visible to an attached observer is announced.`, + authority: `packages/db/src/query/live/ARCHITECTURE.md law 16; glossary "crash" and "cleanup"; the coverage-map decision that cleanup under a seed keeps the seed`, + limits: `A plain controlled Collection, not a live-query Collection; keys a..c, versions 0..3, up to ${MAX_EVENTS} steps; rows compared as sets; handoff timing, on-demand predicates and pending subset loads, streamed errors, and consumption belong to the focused tests in live-query-observer.test.ts.`, + owner: `Observer hydration under terminal statuses`, + }, + histories: { + arbitrary: history, + states: reachStates, + runs: 100, + fixedSeeds: [20260, -88123], + constructed: [ + { + name: `crash while attached, then cleanup`, + enters: [ + `crashed-under-seed:attached`, + `cleanup-after-failure-ended-seed`, + ], + build: startSeededWith(`attach`, `crash`, `cleanup`), + }, + { + name: `first-load failure while attached, then cleanup`, + enters: [ + `error-under-seed:attached`, + `cleanup-after-failure-ended-seed`, + ], + build: startSeededWith(`attach`, `fail`, `cleanup`), + }, + { + name: `crash before attach`, + enters: [`crashed-under-seed:detached`, `attach-to-failure-under-seed`], + build: startSeededWith(`crash`, `attach`, `cleanup`), + }, + { + name: `first-load failure before attach`, + enters: [`error-under-seed:detached`, `attach-to-failure-under-seed`], + build: startSeededWith(`fail`, `attach`), + }, + { + name: `cleanup and restart while attached`, + enters: [`cleaned-up-under-seed:attached`, `restart-under-seed`], + build: startSeededWith(`attach`, `cleanup`, `restart`), + }, + { + name: `cleanup before attach`, + enters: [`cleaned-up-under-seed:detached`], + build: startSeededWith(`cleanup`, `attach`, `ready`), + }, + { + name: `readiness while detached`, + enters: [`ready-under-seed:detached`], + build: startSeededWith(`ready`), + }, + { + name: `readiness while attached`, + enters: [`ready-under-seed:attached`], + build: startSeededWith(`attach`, `ready`), + }, + { + name: `rewrite a row after readiness, cleanup, and restart`, + enters: [`restart-rewrites-row-from-before-cleanup`], + build: rewriteAfterRestart(`ready`), + }, + { + name: `rewrite a row after a failure, cleanup, and restart`, + enters: [`restart-rewrites-row-from-before-cleanup`], + build: rewriteAfterRestart(`fail`), + }, + { + name: `reacquire an unseeded granular observer's demand on restart`, + enters: [ + `restart-rewrites-row-from-before-cleanup`, + `restart-reacquires-row-from-before-cleanup`, + ], + build: rewriteAfterRestart(`ready`, { + seed: null, + mode: `granular`, + syncMode: `on-demand`, + }), + }, ], - ] - for (const mode of [`granular`, `wholesale`] as const) { - it.each(replays)(`${mode} observer %s`, async (_name, events) => { - await runHistory({ mode, seed: [[`a`, 1]], events }) - }) - } + fixed: ([`granular`, `wholesale`] as const).flatMap((mode) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + replays.flatMap(([name, events, unseeded]) => + [[[`a`, 1]] as Array<[Key, number]>, null] + .filter((seed) => seed !== null || unseeded !== undefined) + .map((seed) => ({ + name: `${mode} observer of an ${syncMode} source${seed ? `` : ` without a seed`} ${name}`, + history: { mode, syncMode, seed, events: events.map(toEvent) }, + })), + ), + ), + ), + }, + // Wrong answers the check must reject: a failure shown as `ready` under + // the seed (law 1), and a change the listener never heard about (law 4). + calibrations: [ + { + name: `failure shown as ready`, + law: `view`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => { + const record = value as StepRecord + return !(`skipped` in record) && failures.has(record.input.after) + }) + if (at < 0) return undefined + return trace.map((entry, index) => { + if (index !== at) return entry + const record = entry.value as Extract + return { + ...entry, + value: { + ...record, + output: { + ...record.output, + view: { ...record.output.view, status: `ready` }, + }, + }, + } + }) + }, + }, + { + name: `unannounced change`, + law: `announcement`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => { + const record = value as StepRecord + return ( + !(`skipped` in record) && + record.output.lastRead !== undefined && + record.output.view.rows.length > 0 + ) + }) + if (at < 0) return undefined + return trace.map((entry, index) => { + if (index !== at) return entry + const record = entry.value as Extract + return { + ...entry, + value: { + ...record, + output: { + ...record.output, + lastRead: { ...record.output.lastRead!, rows: [] }, + }, + }, + } + }) + }, + }, + ], + model: hydrationModel, + reached: (_history, _trace, expected) => expected.reached, + driver: driveHistory, + check: checkHistory, }) /** @@ -636,6 +1061,7 @@ for (const mode of [`granular`, `wholesale`] as const) ? oracleReplayTest(it) : oracleReplayTest(it, property) test(`${mode} seeded ready-subset handoff (${campaign})`, async () => { + oracleRoute(campaign === `fixed` ? `fixed seed` : `random seed`) const reach = createOracleReach( `${property}/${mode}/${campaign}`, [`ready while subset held`, `subset-only handoff`], @@ -665,6 +1091,7 @@ for (const mode of [`granular`, `wholesale`] as const) }) } declareOracle({ + owns: [`src/live-query-observer.ts`, `src/client.ts#DbClient.hydrate`], grammar: [subsetHandoffArbitrary], check: [checkSubsetHandoff], }) diff --git a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts index 8566802c40..3bd85e74d6 100644 --- a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts @@ -8,8 +8,11 @@ import { createLiveQueryObserver } from '../src/live-query-observer.js' import { Query, createLiveQueryCollection } from '../src/query/index.js' import { BTreeIndex } from '../src/indexes/btree-index.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -312,7 +315,11 @@ async function checkHistory( } } +// What this oracle owns: the gap report judges every function in +// src/live-query-observer.ts by this file's snapshot histories, which read +// retained getSnapshot() objects and the health tree of the observer. declareOracle({ + owns: [`src/live-query-observer.ts`], check: [checkRead, checkHistory, checkHealthHistory], model: [modelRows], }) @@ -348,12 +355,12 @@ describe(`retained live-query snapshots are point-in-time`, () => { reach.beginHistory() return checkHistory(shape, attached, commands, order, reach) }) - await fc.assert( - property, + const parameters = campaign === `fixed` - ? { numRuns: oracleRuns(40), seed: 2043 } - : oraclePropertyOptions(40, PROPERTY), - ) + ? oracleFixedSeed(2043, { numRuns: oracleRuns(40) }) + : oraclePropertyOptions(40, PROPERTY) + oracleRoute(oracleRouteOf(parameters)) + await fc.assert(property, parameters) reach.assertReached() console.info( `snapshot-getter ${shape} ${attached} ${campaign}`, @@ -363,6 +370,7 @@ describe(`retained live-query snapshots are point-in-time`, () => { ) it(`reads a snapshot after several newer snapshots were built`, async () => { + oracleRoute(`fixed history`) const commands: Array = [ { type: `upsert`, id: `a`, version: 1 }, { type: `upsert`, id: `b`, version: 1 }, @@ -385,6 +393,7 @@ describe(`retained live-query snapshots are point-in-time`, () => { }) it(`rejects a snapshot that reads the live Collection on first access`, async () => { + oracleRoute(`calibration`) // Calibration: the #1542 design. Its getters read the live query when // first accessed instead of the rows captured at snapshot time. const { observer, query, source } = setup(`ordered`) @@ -642,13 +651,19 @@ describe(`a snapshot shows the current health tree`, () => { await checkHealthHistory(commands, reach) }, ) - // Fixed campaign: a repeatable baseline. Seed 695 is arbitrary. - await fc.assert(property, { numRuns: oracleRuns(80), seed: 695 }) - await fc.assert(property, oraclePropertyOptions(80)) + // Fixed campaign: a repeatable baseline. Seed 695 is arbitrary. Each + // campaign registers its own route before it runs. + const fixed = oracleFixedSeed(695, { numRuns: oracleRuns(80) }) + oracleRoute(oracleRouteOf(fixed)) + await fc.assert(property, fixed) + const random = oraclePropertyOptions(80) + oracleRoute(oracleRouteOf(random)) + await fc.assert(property, random) reach.assertReached() }) it(`shows a health-only change made between a detached read and attach`, async () => { + oracleRoute(`fixed history`) // Replay of the reported trace: a framework renders (detached read), a // source reports a problem before commit, the framework commits // (attach), then reads again. @@ -968,22 +983,24 @@ describe(`metadata snapshots share immutable row containers`, () => { ], { fixedHistories: examples.length }, ) - // fast-check 3 emits examples before generated histories. A failed + // fast-check runs examples before generated histories. A failed // campaign throws before aggregate reach; path replay disables it. let histories = 0 + const campaignParameters = { + ...oraclePropertyOptions( + 40, + `live-query-observer.row-container-sharing`, + { examples }, + ), + ...(campaign === `fixed` ? oracleFixedSeed(1101) : {}), + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(containerHistory, async (scenario) => { reach.beginHistory({ fixed: histories++ < examples.length }) await checkContainerHistory(scenario, reach) }), - { - ...oraclePropertyOptions( - 40, - `live-query-observer.row-container-sharing`, - { examples }, - ), - ...(campaign === `fixed` ? { seed: 1101 } : {}), - }, + campaignParameters, ) reach.assertReached() process.stdout.write( @@ -1317,6 +1334,7 @@ describe(`snapshot capture paths preserve row and layout laws`, () => { // failure cannot hide the distinct layout premise during RED verification. for (const action of [`discard-write`, `discard-move`] as const) it(`reads committed rows after ${action}`, async () => { + oracleRoute(`fixed history`) const reach = createOracleReach( `capture-discard-replay`, [ @@ -1418,17 +1436,19 @@ describe(`snapshot capture paths preserve row and layout laws`, () => { { fixedHistories: examples.length }, ) let histories = 0 + const runParameters = { + ...oraclePropertyOptions(40, `live-query-observer.capture-paths`, { + examples, + }), + ...(campaign === `fixed` ? oracleFixedSeed(2346) : {}), + } + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty(capturePathHistory, async (scenario) => { reach.beginHistory({ fixed: histories++ < examples.length }) await checkCapturePathHistory(scenario, reach) }), - { - ...oraclePropertyOptions(40, `live-query-observer.capture-paths`, { - examples, - }), - ...(campaign === `fixed` ? { seed: 2346 } : {}), - }, + runParameters, ) reach.assertReached() process.stdout.write( @@ -1744,6 +1764,13 @@ describe(`publications preserve nested record shape`, () => { { fixedHistories: examples.length }, ) let histories = 0 + const parameters = { + ...oraclePropertyOptions(40, `live-query-observer.record-shape`, { + examples, + }), + ...(campaign === `fixed` ? oracleFixedSeed(234602) : {}), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(payloadHistory, async (scenario) => { reach.beginHistory({ fixed: histories++ < examples.length }) @@ -1764,12 +1791,7 @@ describe(`publications preserve nested record shape`, () => { { cause: errors[0] }, ) }), - { - ...oraclePropertyOptions(40, `live-query-observer.record-shape`, { - examples, - }), - ...(campaign === `fixed` ? { seed: 234602 } : {}), - }, + parameters, ) reach.assertReached() process.stdout.write( @@ -1973,6 +1995,11 @@ describe(`array value publication`, () => { `live-query-observer.sparse-array`, sparseReceivingStates, ) + const parameters = { + ...oraclePropertyOptions(20, `live-query-observer.sparse-array`), + ...(campaign === `fixed` ? oracleFixedSeed(234603) : {}), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(sparseArrayCase, async (spec) => { reach.beginHistory() @@ -2004,10 +2031,7 @@ describe(`array value publication`, () => { { cause: errors[0] }, ) }), - { - ...oraclePropertyOptions(20, `live-query-observer.sparse-array`), - ...(campaign === `fixed` ? { seed: 234603 } : {}), - }, + parameters, ) reach.assertReached() process.stdout.write( diff --git a/packages/db/tests/live-query-preload-indexing-oracle.test.ts b/packages/db/tests/live-query-preload-indexing-oracle.test.ts index 9a5189449d..808b9b97fc 100644 --- a/packages/db/tests/live-query-preload-indexing-oracle.test.ts +++ b/packages/db/tests/live-query-preload-indexing-oracle.test.ts @@ -22,6 +22,7 @@ import { expectedIndexing, indexConfigurations, } from './conformance/indexing-config-oracle' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration' import { createOracleReach } from './oracle-reach' import { withOracleCleanup } from './utils' @@ -48,7 +49,10 @@ function preloadIndexHistories() { ), ) } +// What this oracle owns: the gap report judges DbClient.preloadLiveQuery, +// the explicit preload admission path, by this file's finite declaration grammar. declareOracle({ + owns: [`src/client.ts#DbClient.preloadLiveQuery`], grammar: [preloadIndexHistories], model: [expectedIndexing], check: [expectIndexingAdmission], @@ -57,6 +61,7 @@ declareOracle({ test.each(preloadIndexHistories())( `preload indexing admission: %j`, async ({ first, second, phase, identity }) => { + oracleRoute(`case`) const reach = createOracleReach(`preload indexing/${phase}/${identity}`, [ `admission`, ...(first ? [`original-reuse`] : []), diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index c4e27410b2..0ee0996096 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration' import { confirmEverything, @@ -18,7 +19,7 @@ import type { Handlers, Orders } from './local-only-direct-write-fixture' * transaction, and runs the user's handler. * * The change-event history oracle drives this path through generated - * histories and checks every publication. These witnesses pin the guard's + * histories and checks every publication. These witnesses fix the guard's * fallback cases, which that oracle does not generate: removing the guard * hides a direct write under a pending overlay and holds it behind a * persisting transaction, for every operation type. @@ -79,7 +80,16 @@ function expectDirectMatchesConfirmed( expect(direct.remaining).toBe(0) } -declareOracle({ check: [expectDirectMatchesConfirmed] }) +// What this oracle owns: the gap report judges the local-only direct-write +// guard in CollectionMutationsManager.commitLocalOnlyDirect and the local-only +// Collection options by this file's fixed direct-write histories. +declareOracle({ + owns: [ + `src/local-only.ts`, + `src/collection/mutations.ts#CollectionMutationsManager.commitLocalOnlyDirect`, + ], + check: [expectDirectMatchesConfirmed], +}) describe(`local-only direct writes match the confirmed optimistic path`, () => { const histories: Record> = { @@ -136,6 +146,7 @@ describe(`local-only direct writes match the confirmed optimistic path`, () => { for (const [name, writes] of Object.entries(histories)) { it(`for ${name}`, async () => { + oracleRoute(`fixed history`) const direct = await observe({}, writes) const reference = await observe(confirmEverything, writes) diff --git a/packages/db/tests/local-storage-fixtures.ts b/packages/db/tests/local-storage-fixtures.ts index 225bd169fc..9d6d4d82f3 100644 --- a/packages/db/tests/local-storage-fixtures.ts +++ b/packages/db/tests/local-storage-fixtures.ts @@ -1,5 +1,6 @@ -// Shared fixtures for the LocalStorage write-order tests. The law and its -// generated histories are owned by local-storage-order-oracle.test.ts. +// Shared fixtures for the LocalStorage write-order tests. The law is stated in +// local-storage-order-oracle.test.ts; its enumerated histories run in +// local-storage-write-order-oracle.test.ts. import { expect } from 'vitest' export type Row = { id: string; value: number } diff --git a/packages/db/tests/local-storage-order-oracle.test.ts b/packages/db/tests/local-storage-order-oracle.test.ts index 6b21328620..f4dcb17fed 100644 --- a/packages/db/tests/local-storage-order-oracle.test.ts +++ b/packages/db/tests/local-storage-order-oracle.test.ts @@ -8,12 +8,11 @@ * * The independent model folds accepted whole-row updates in author order. It * does not model handlers, optimistic overlays, storage caches, or sync queues. - * The legal histories below start with two stored rows and submit two updates - * without waiting. They vary same/disjoint keys, handler completion order, - * and both application decisions for each update. - * At the final settlement checkpoint, public rows, durable rows, and a fresh - * Collection must equal that fold. The held-handler checkpoint verifies that - * no later mutation reports persistence ahead of its undecided predecessor. + * Every short history of automatic updates and deletes, with every handler + * completion order and decision, runs as the `local-storage.write-order` + * property in `local-storage-write-order-oracle.test.ts`. That property checks + * the held-handler law after each handler finishes, and the settled law at the + * end. The witnesses below cover the histories that it excludes. * A handler may admit a nested automatic mutation without awaiting it. Awaiting * that mutation's persistence or a later manual acceptance from the earlier * handler would form a cycle under the order law; the guide excludes both. @@ -33,12 +32,13 @@ * covers direct and queued success and Storage failure; it does not make * several distinct storage keys one atomic transaction. */ -import { describe, expect, it } from 'vitest' +import { expect, it } from 'vitest' import { createCollection } from '../src/collection/index' import { DbClient, collectionOptions } from '../src/client' import { createDeferred } from '../src/deferred' import { localStorageCollectionOptions } from '../src/local-storage' import { createTransaction } from '../src/transactions' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration' import { observeHistoryPromise, @@ -67,12 +67,18 @@ function expectedRows( return [...rows.values()].sort((a, b) => a.id.localeCompare(b.id)) } -declareOracle({ model: [expectedRows] }) +// What this oracle owns: the gap report judges every function in +// src/local-storage.ts by this file's fixed LocalStorage write-order histories. +declareOracle({ + owns: [`src/local-storage.ts`], + model: [expectedRows], +}) // The outer handler admits a nested write while it is held. The independent // authored fold expects the outer row before the inner row. The storage trace // checks that order, while the held checkpoint rules out early inner persistence. it('persists a nested fire-and-forget mutation after its handler returns', async () => { + oracleRoute(`fixed history`) const storage = makeSeededStorage() const snapshots: Array> = [] const setItem = storage.setItem @@ -132,6 +138,7 @@ it('persists a nested fire-and-forget mutation after its handler returns', async * stays at 0; after both receipts it is 2. The materialized Collection and a * fresh restore are the public observations. */ it('orders module-level manual acceptance with a DbClient Collection', async () => { + oracleRoute(`fixed history`) const storage = makeSeededStorage() const entered = createDeferred() const release = createDeferred() @@ -228,6 +235,7 @@ it('orders module-level manual acceptance with a DbClient Collection', async () // Waiting for the rejected receipt is a neighboring control: both cuts must // have the same durable rows, without an empty failed slot delaying the first. it('persists a direct write at return after a synchronous handler failure', async () => { + oracleRoute(`fixed history`) const storage = makeSeededStorage() const failure = new Error('update rejected before writing') const collection = createCollection( @@ -277,175 +285,3 @@ it('persists a direct write at return after a synchronous handler failure', asyn () => [() => collection.cleanup()], ) }) - -// This finite grammar covers same-key and disjoint-key updates and both handler -// completion orders and all decision pairs. It excludes peer writes and -// manual transactions; those require different event and ownership boundaries. -for (const secondId of ['a', 'b']) { - for (const completion of ['first', 'second']) { - for (const accepted of [ - [true, true], - [true, false], - [false, true], - [false, false], - ]) { - describe(`two ${secondId === 'a' ? 'same-key' : 'disjoint-key'} writes, ${completion} handler first, decisions ${accepted.join('/')}`, () => { - it('persists and publishes the authored result', async () => { - const storage = makeSeededStorage() - const gate = [createDeferred(), createDeferred()] - const entered = [createDeferred(), createDeferred()] - const edits: Array = [ - { id: 'a', value: 1 }, - { id: secondId, value: 2 }, - ] - const makeCollection = (withHandler: boolean) => - createCollection( - localStorageCollectionOptions({ - storageKey: 'rows', - storage, - storageEventApi: { - addEventListener() {}, - removeEventListener() {}, - }, - getKey: (row) => row.id, - ...(withHandler - ? { - onUpdate: async ({ - transaction, - }: { - transaction: { mutations: Array<{ modified: Row }> } - }) => { - const index = - transaction.mutations[0]!.modified.value - 1 - entered[index]!.resolve() - await gate[index]!.promise - }, - } - : {}), - }), - ) - const collection = makeCollection(true) - let reopened: ReturnType | undefined - await withHistoryCleanup( - async () => { - await collection.preload() - const transactions = edits.map((edit) => - collection.update(edit.id, (draft) => { - draft.value = edit.value - }), - ) - const settled = [false, false] - const outcomes = transactions.map((tx, index) => - tx.when(`settled`).then( - () => { - settled[index] = true - return true - }, - () => { - settled[index] = true - return false - }, - ), - ) - await Promise.all(entered.map((item) => item.promise)) - const first = completion === 'first' ? 0 : 1 - if (accepted[first]) gate[first]!.resolve() - else gate[first]!.reject(new Error(`handler ${first} rejected`)) - await Promise.resolve() - await Promise.resolve() - if (first === 1) { - expect( - settled[1], - 'later persistence waits for the earlier decision', - ).toBe(false) - expect(storedRows(storage.getItem('rows'))).toEqual(initial) - } - if (accepted[1 - first]) gate[1 - first]!.resolve() - else - gate[1 - first]!.reject( - new Error(`handler ${1 - first} rejected`), - ) - expect(await Promise.all(outcomes)).toEqual(accepted) - const expected = expectedRows(edits, accepted) - expect(storedRows(storage.getItem('rows'))).toEqual(expected) - expect(sortedRows(collection.values())).toEqual(expected) - reopened = makeCollection(false) - await reopened.preload() - expect(sortedRows(reopened.values())).toEqual(expected) - }, - () => [ - () => gate.forEach((item) => item.resolve()), - () => reopened?.cleanup(), - () => collection.cleanup(), - ], - ) - }) - }) - } - } -} - -// An update followed by a delete distinguishes ordering from a simple -// last-value replacement. The same authored fold removes the row if the -// delete is accepted, even when its handler completes before the update. -for (const accepted of [ - [true, true], - [true, false], - [false, true], - [false, false], -]) { - it(`keeps an update followed by delete in author order with decisions ${accepted.join('/')}`, async () => { - const storage = makeSeededStorage() - const gate = [createDeferred(), createDeferred()] - const entered = [createDeferred(), createDeferred()] - const collection = createCollection( - localStorageCollectionOptions({ - storageKey: 'rows', - storage, - storageEventApi: { addEventListener() {}, removeEventListener() {} }, - getKey: (row) => row.id, - onUpdate: async () => { - entered[0]!.resolve() - await gate[0]!.promise - }, - onDelete: async () => { - entered[1]!.resolve() - await gate[1]!.promise - }, - }), - ) - await withHistoryCleanup( - async () => { - await collection.preload() - const update = collection.update('a', (draft) => { - draft.value = 1 - }) - const deletion = collection.delete('a') - const outcomes = [update, deletion].map((tx) => - tx.when(`settled`).then( - () => true, - () => false, - ), - ) - await Promise.all(entered.map((item) => item.promise)) - if (accepted[1]) gate[1]!.resolve() - else gate[1]!.reject(new Error('delete rejected')) - await Promise.resolve() - await Promise.resolve() - expect(storedRows(storage.getItem('rows'))).toEqual(initial) - if (accepted[0]) gate[0]!.resolve() - else gate[0]!.reject(new Error('update rejected')) - expect(await Promise.all(outcomes)).toEqual(accepted) - const expected = accepted[1] - ? [{ id: 'b', value: 0 }] - : expectedRows([{ id: 'a', value: 1 }], [accepted[0]!]) - expect(storedRows(storage.getItem('rows'))).toEqual(expected) - expect(sortedRows(collection.values())).toEqual(expected) - }, - () => [ - () => gate.forEach((item) => item.resolve()), - () => collection.cleanup(), - ], - ) - }) -} diff --git a/packages/db/tests/local-storage-peers-oracle.test.ts b/packages/db/tests/local-storage-peers-oracle.test.ts new file mode 100644 index 0000000000..52a50466f8 --- /dev/null +++ b/packages/db/tests/local-storage-peers-oracle.test.ts @@ -0,0 +1,532 @@ +/** + * LocalStorage Collections that share a storage key agree with storage: same- + * tab peers at once, other tabs when their storage events arrive. + * + * The LocalStorage guide ("Synchronization") promises: + * + * - Two active Collections in one tab that use the same `storage` object and + * `storageKey` sync without a browser event. A successful write or + * `clearStorage()` updates both public snapshots. + * - A Collection's pending optimistic rows stay visible until its own + * mutations settle. A pending mutation can stay visible through a + * `clearStorage()` and later persist. + * - Each write stores a complete snapshot and preserves rows that other tabs + * already wrote, even before their events arrive. + * - Other tabs see accepted changes when their storage events arrive. The + * same-tab mechanism does not connect distinct Storage objects. + * + * This property runs every history of up to three actions across three + * Collections: A and B in one tab, C in another tab over the same bytes. After + * each action it compares every Collection's public rows and the stored rows + * with an independent model. At the end, a fresh Collection must restore the + * stored rows. + * + * Keys are the strings a, b, and c and the number 7, so both key encodings + * cross between tabs ("New writes encode string and number keys with a type + * prefix"). C can also `reload()`, which must show the stored rows even when + * no storage event arrived (coverage map, LocalStorage reload witnesses). + * + * Write paths. A writes through automatic handlers and holds one update. B + * writes directly or through manual acceptance (`utils.acceptMutations`). C + * uses a custom parser and has insert, update, and delete handlers, so its + * writes take the handler path and the parser's read-back path. Any of the + * three can call `clearStorage()`. + * + * Limits. Values are numbers, so value normalization is not exercised. Only A + * has a held handler, and at most one write is held at a time. A Collection + * never writes a key whose presence it disagrees with storage about (a write + * race between tabs, which the guide says is not atomic). Only C reloads. + * Startup errors, storage faults, value-normalizing parsers, and cleanup are + * outside this property. + */ +import { createDeferred } from '../src/deferred' +import { createCollection } from '../src/collection/index' +import { localStorageCollectionOptions } from '../src/local-storage' +import { createTransaction } from '../src/transactions' +import { createHost } from './local-storage-peer-fixtures' +import { withHistoryCleanup } from './optimistic-history-oracle' +import { declareOracle } from './oracle-declaration' +import { oracleProperty, pass, violation } from './oracle-property' + +// -- Histories ------------------------------------------------------------------ +// +// Tab 1 holds A and B; tab 2 holds C. Stored rows start as a=0 and b=0. An +// action is a direct write by one Collection (update or delete of a stored +// key, or insert of key c), a held update of key a by A that a later action +// accepts or rejects, `clearStorage()` from A, or the delivery of a tab's +// pending storage events. The grammar generates only actions that are legal +// in the model's state: a write needs its key's presence to agree between the +// writer's view and storage, A holds at most one write and writes nothing else +// while it is held, and a delivery needs a pending event. + +type Collection = `A` | `B` | `C` +type Key = `a` | `b` | `c` | `7` +type Action = + | { + kind: `write` + by: Collection + op: `update` | `delete` | `insert` + key: Key + manual?: true + } + | { kind: `hold` } + | { kind: `settle`; accept: boolean } + | { kind: `clear`; by: Collection } + | { kind: `deliver`; tab: 1 | 2 } + | { kind: `reload` } +type History = ReadonlyArray +type Rows = Record + +const MAX_ACTIONS = 3 +const tabOf = (collection: Collection) => (collection === `C` ? 2 : 1) +const valueOf = (step: number, by: Collection) => + (step + 1) * 10 + [`A`, `B`, `C`].indexOf(by) + +// -- Model ---------------------------------------------------------------------- +// +// The model keeps the stored rows, and for each tab the keys that the other +// tab changed since this tab last read storage. A tab reads storage when its +// storage event is delivered and when any of its Collections writes: a write +// picks up every row currently in storage, for every Collection in the +// writing tab (maintainer decision D43, 2026-11-02; the gap report's +// comparison gaps raised the question). A tab's public rows are storage, +// except for stale keys, which may show the value they had when the tab last +// read storage, or the stored value, until the tab reads again. +// +// A's held update overlays A's public rows only, until it settles. While it is +// held, sync transactions that reach A are held too, and they apply in the +// publication that drops A's optimistic state (glossary, "accepted sync +// transaction"). So during the hold, A may show either what it showed when the +// hold began or what it would show otherwise, for every other key. Once the +// held write settles, A agrees with storage again. The driver accepts a write +// that is still held when the history ends, so the model does too before the +// fresh restore. + +type Seen = Map> +type State = { + stored: Rows + stale: [undefined, Seen, Seen] + pendingEvents: [undefined, boolean, boolean] + held: { value: number; frozen: Allowed } | undefined +} + +const initialRows: Rows = { a: 0, b: 0 } + +function initialState(): State { + return { + stored: { ...initialRows }, + stale: [undefined, new Map(), new Map()], + pendingEvents: [undefined, false, false], + held: undefined, + } +} + +/** The values a Collection may show, per key. */ +type Allowed = Map> + +function syncedAllowed(state: State, collection: Collection): Allowed { + const stale = state.stale[tabOf(collection)] + const allowed: Allowed = new Map() + for (const key of [`a`, `b`, `c`, `7`]) { + const stored = state.stored[key] + allowed.set(key, stale.has(key) ? [...stale.get(key)!, stored] : [stored]) + } + return allowed +} + +function allowedFor(state: State, collection: Collection): Allowed { + const allowed = syncedAllowed(state, collection) + if (collection === `A` && state.held !== undefined) { + for (const [key, values] of allowed) + allowed.set(key, [...state.held.frozen.get(key)!, ...values]) + allowed.set(`a`, [state.held.value]) + } + return allowed +} + +/** Whether a Collection's tab may believe a key is present. */ +function believed(state: State, collection: Collection, key: string) { + const stale = state.stale[tabOf(collection)] + return stale.has(key) ? [...stale.get(key)!][0] : state.stored[key] +} + +/** + * Records that `tab` wrote `key`. The other tab's view of the key is now stale. + * The writing tab reads storage, so none of its keys is stale (D43). + */ +function changedBy( + state: State, + tab: 1 | 2, + key: string, + before: number | undefined, +) { + const other = tab === 1 ? 2 : 1 + const otherStale = state.stale[other] + if (!otherStale.has(key)) otherStale.set(key, new Set([before])) + state.stale[tab].clear() + state.pendingEvents[other] = true +} + +function apply(state: State, action: Action, step: number): void { + if (action.kind === `write`) { + const before = state.stored[action.key] + if (action.op === `delete`) delete state.stored[action.key] + else state.stored[action.key] = valueOf(step, action.by) + changedBy(state, tabOf(action.by), action.key, before) + } else if (action.kind === `hold`) { + state.held = { value: valueOf(step, `A`), frozen: allowedFor(state, `A`) } + } else if (action.kind === `settle`) { + const held = state.held! + state.held = undefined + if (action.accept) { + const before = state.stored.a + state.stored.a = held.value + changedBy(state, 1, `a`, before) + } + } else if (action.kind === `clear`) { + const tab = tabOf(action.by) + for (const key of Object.keys(state.stored)) + changedBy(state, tab, key, state.stored[key]) + state.stored = {} + state.stale[tab].clear() + } else if (action.kind === `reload`) { + // A reload reads storage; a pending event, once delivered, changes nothing. + state.stale[2].clear() + } else { + state.stale[action.tab].clear() + state.pendingEvents[action.tab] = false + } +} + +function legalActions(state: State): Array { + const actions: Array = [] + for (const by of [`A`, `B`, `C`] as const) { + if (by === `A` && state.held !== undefined) continue + for (const key of [`a`, `b`, `c`, `7`] as const) { + // The writer's view and storage must agree on the key's presence. + const seen = believed(state, by, key) !== undefined + const stored = state.stored[key] !== undefined + if (seen !== stored) continue + const ops = stored + ? ([`update`, `delete`] as const) + : key === `c` || key === `7` + ? ([`insert`] as const) + : [] + for (const op of ops) { + actions.push({ kind: `write`, by, op, key }) + if (by === `B`) + actions.push({ kind: `write`, by, op, key, manual: true }) + } + } + } + if ( + state.held === undefined && + state.stored.a !== undefined && + believed(state, `A`, `a`) !== undefined + ) + actions.push({ kind: `hold` }) + if (state.held !== undefined) { + actions.push({ kind: `settle`, accept: true }) + actions.push({ kind: `settle`, accept: false }) + } + for (const by of [`A`, `B`, `C`] as const) actions.push({ kind: `clear`, by }) + actions.push({ kind: `reload` }) + for (const tab of [1, 2] as const) + if (state.pendingEvents[tab]) actions.push({ kind: `deliver`, tab }) + return actions +} + +function enumerate(): Array { + const histories: Array = [] + const extend = (history: Array) => { + if (history.length > 0) histories.push(history) + if (history.length === MAX_ACTIONS) return + const state = initialState() + history.forEach((action, step) => apply(state, action, step)) + for (const action of legalActions(state)) extend([...history, action]) + } + extend([]) + return histories +} + +/** The allowed observations after each action, and the rows a restore must show. */ +function expectedResult(history: History) { + const state = initialState() + const steps = history.map((action, step) => { + apply(state, action, step) + return { + A: allowedFor(state, `A`), + B: allowedFor(state, `B`), + C: allowedFor(state, `C`), + stored: { ...state.stored }, + } + }) + if (state.held !== undefined) + apply(state, { kind: `settle`, accept: true }, history.length) + return { steps, restored: { ...state.stored } } +} + +// -- Production driver ------------------------------------------------------------ +// +// Two hosts over one byte map are two tabs of one origin (peer fixtures). A +// write by one tab queues a storage event for the other tab; the history +// decides when it is delivered. A's update handler holds only the held write. +// After each action the driver waits for that action's receipts to settle and +// for the event loop to drain, then records every Collection's public rows and +// the stored rows. It records; it does not judge. + +const rowsOf = ( + values: Iterable<{ id: string | number; value: number }>, +): Rows => + Object.fromEntries([...values].map((row) => [String(row.id), row.value])) + +async function drive( + history: History, + observe: (checkpoint: string, value: unknown) => void, +) { + const data = new Map() + data.set( + `rows`, + JSON.stringify( + Object.fromEntries( + Object.entries(initialRows).map(([id, value]) => [ + `s:${id}`, + { versionKey: `initial-${id}`, data: { id, value } }, + ]), + ), + ), + ) + const hosts = [createHost(data), createHost(data)] as const + const pending = [false, false] + // A write through one tab's Storage object is an event for the other tab. + hosts.forEach((host, index) => { + const { setItem, removeItem } = host.storage + host.storage.setItem = (key, value) => { + setItem(key, value) + pending[1 - index] = true + } + host.storage.removeItem = (key) => { + removeItem(key) + pending[1 - index] = true + } + }) + let gate: ReturnType> | undefined + let holdNext = false + // A holds its next update when asked. C's handlers finish at once, and C's + // parser is a custom object, so C takes the handler and read-back paths. + const holdingUpdate = async () => { + if (!holdNext) return + holdNext = false + await gate!.promise + } + const parser = { parse: JSON.parse, stringify: JSON.stringify } + const make = ( + host: (typeof hosts)[number], + id: string, + kind: `holding` | `plain` | `handled`, + ) => + createCollection( + localStorageCollectionOptions<{ id: string | number; value: number }>({ + id, + storageKey: `rows`, + storage: host.storage, + storageEventApi: host.events, + getKey: (row) => row.id, + ...(kind === `holding` ? { onUpdate: holdingUpdate } : {}), + ...(kind === `handled` + ? { + parser, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + } + : {}), + }), + ) + const collections = { + A: make(hosts[0], `A`, `holding`), + B: make(hosts[0], `B`, `plain`), + C: make(hosts[1], `C`, `handled`), + } + let restored: ReturnType | undefined + let heldReceipt: Promise | undefined + const drain = () => new Promise((resolve) => setTimeout(resolve, 0)) + await withHistoryCleanup( + async () => { + await Promise.all(Object.values(collections).map((c) => c.preload())) + for (const [step, action] of history.entries()) { + if (action.kind === `write`) { + const collection = collections[action.by] + const value = valueOf(step, action.by) + // Key 7 is the number 7, so it takes the numeric key encoding. + const id = action.key === `7` ? 7 : action.key + const write = () => + action.op === `insert` + ? collection.insert({ id, value }) + : action.op === `update` + ? collection.update(id, (draft) => { + draft.value = value + }) + : collection.delete(id) + if (action.manual) { + const transaction = createTransaction({ + autoCommit: false, + mutationFn: ({ transaction: accepted }) => + collection.utils.acceptMutations(accepted), + }) + transaction.mutate(write) + await transaction.commit().catch(() => undefined) + } else + await write() + .when(`settled`) + .catch(() => undefined) + } else if (action.kind === `hold`) { + gate = createDeferred() + holdNext = true + const transaction = collections.A.update(`a`, (draft) => { + draft.value = valueOf(step, `A`) + }) + heldReceipt = transaction.when(`settled`).catch(() => undefined) + } else if (action.kind === `settle`) { + if (action.accept) gate!.resolve() + else gate!.reject(new Error(`held write rejected`)) + await heldReceipt + } else if (action.kind === `clear`) { + collections[action.by].utils.clearStorage() + } else if (action.kind === `reload`) { + await collections.C.reload() + } else { + const index = action.tab - 1 + if (pending[index]) { + pending[index] = false + hosts[index]!.deliver(`rows`) + } + } + await drain() + const raw = data.get(`rows`) + observe(`after action ${step + 1}`, { + A: rowsOf(collections.A.values()), + B: rowsOf(collections.B.values()), + C: rowsOf(collections.C.values()), + stored: + raw === undefined + ? {} + : rowsOf( + Object.values( + JSON.parse(raw) as Record< + string, + { data: { id: string; value: number } } + >, + ).map(({ data: row }) => row), + ), + }) + } + gate?.resolve() + await heldReceipt + restored = make(createHost(data), `restored`, `plain`) + await restored.preload() + observe(`fresh restore`, rowsOf(restored.values())) + }, + () => [ + () => gate?.resolve(), + () => restored?.cleanup(), + ...Object.values(collections).map((c) => () => c.cleanup()), + ], + ) +} + +// -- Check ------------------------------------------------------------------------ +// +// After each action, stored rows must equal the model exactly, and each +// Collection's public value for each key must be one the model allows. At the +// end, a fresh Collection restores the stored rows. + +function checkHistory( + history: History, + observations: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +) { + const json = (value: unknown) => JSON.stringify(value) + for (const [step, allowed] of expected.steps.entries()) { + const checkpoint = `after action ${step + 1}` + const observed = observations.find( + (entry) => entry.checkpoint === checkpoint, + )?.value as Record<`A` | `B` | `C` | `stored`, Rows> | undefined + if (observed === undefined) + return violation(checkpoint, `the driver recorded no observation`) + if (json(sortKeys(observed.stored)) !== json(sortKeys(allowed.stored))) + return violation( + checkpoint, + `stored ${json(observed.stored)}, expected ${json(allowed.stored)}`, + undefined, + { actual: observed.stored, expected: allowed.stored }, + ) + for (const collection of [`A`, `B`, `C`] as const) { + const rows = observed[collection] + for (const [key, values] of allowed[collection]) { + if (!values.includes(rows[key])) + return violation( + checkpoint, + `${collection} shows ${key}=${rows[key]}, allowed ${json(values.map((v) => v ?? `absent`))} (stored ${json(observed.stored)})`, + ) + } + for (const key of Object.keys(rows)) + if (!allowed[collection].has(key)) + return violation(checkpoint, `${collection} shows unknown key ${key}`) + } + } + const restored = observations.find( + (entry) => entry.checkpoint === `fresh restore`, + ) + if (restored === undefined) + return violation(`fresh restore`, `the driver recorded no restore`) + if ( + json(sortKeys(restored.value as Rows)) !== json(sortKeys(expected.restored)) + ) + return violation( + `fresh restore`, + `restored ${json(restored.value)}, expected ${json(expected.restored)}`, + undefined, + { actual: restored.value, expected: expected.restored }, + ) + return pass +} + +function sortKeys(rows: Rows): Rows { + return Object.fromEntries( + Object.entries(rows).sort(([a], [b]) => a.localeCompare(b)), + ) +} + +const describeAction = (action: Action) => { + if (action.kind === `write`) + return `${action.by}:${action.manual ? `manual-` : ``}${action.op[0]}${action.key}` + if (action.kind === `clear`) return `${action.by}:clear` + if (action.kind === `reload`) return `C:reload` + if (action.kind === `settle`) return action.accept ? `accept` : `reject` + if (action.kind === `deliver`) return `deliver${action.tab}` + return action.kind +} + +// What this oracle owns: the gap report judges every function in +// src/local-storage.ts by this file's generated same-tab and cross-tab peer +// histories. + +declareOracle({ + owns: [`src/local-storage.ts`], +}) + +oracleProperty({ + id: `local-storage.peers`, + law: { + statement: `Collections sharing a LocalStorage key agree with storage: stored rows follow each write applied to the current snapshot; same-tab peers show stored rows once a write or clearStorage() settles, except their own pending optimistic rows; a write makes every Collection in its tab read storage; another tab shows a row the first tab changed once its storage event arrives.`, + authority: `LocalStorage guide, "Synchronization" (same-tab peers, pending optimistic rows, clearStorage, preserved peer rows, storage events); glossary "accepted sync transaction" (held while an optimistic transaction persists); maintainer decision D43, 2026-11-02 (a write reads storage for its tab)`, + limits: `Up to three actions; numeric values; one held write by A; automatic, manual, handler, and custom-parser write paths and clearStorage() from any Collection; no write races, startup errors, storage faults, reload, or cleanup.`, + owner: `LocalStorage Collection`, + }, + histories: enumerate(), + label: (history) => history.map(describeAction).join(` `), + model: expectedResult, + driver: drive, + check: checkHistory, +}) 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 297747cd1d..3a5dc6ec59 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 @@ -33,9 +33,12 @@ import { SerializationError } from '../src/errors' import { localStorageCollectionOptions } from '../src/local-storage' import { createTransaction } from '../src/transactions' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -374,17 +377,21 @@ async function runHistory({ entry, handler, payload }: History) { ) } +// What this oracle owns: the gap report judges every function in +// src/local-storage.ts by this file's JSON-fidelity histories and fixed +// payload cases. declareOracle({ + owns: [`src/local-storage.ts`], check: [runHistory], model: [restoredArray, restoredObject], }) describe(`LocalStorage value fidelity oracle`, () => { if (!oracleReplayRequested()) { - // Pinned witnesses. The first is the reported case: a function property in + // Fixed histories. 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. - const pinned: Array = [ + const fixedPayloads: Array = [ ...lostLeaves.map((make) => make()), lost(`nested function`, { a: { b: () => 1 } }), lost(`function in array`, [1, () => 1]), @@ -422,16 +429,19 @@ describe(`LocalStorage value fidelity oracle`, () => { ] as const for (const entry of entries) { for (const handler of [true, false]) { - for (const payload of pinned) { + for (const payload of fixedPayloads) { 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 })) + it(`${entry} ${handler ? `with` : `without`} a handler of ${payload.label} ${payload.restored === LOST ? `rejects` : `persists its JSON form`}`, () => { + oracleRoute(`fixed history`) + return runHistory({ entry, handler, payload }) + }) } } // Storage must hold the JSON the check accepted. This `toJSON` returns // the full value once and then a function, which JSON would drop, so a // second serialization before the write would store a different row. it(`${entry} stores the JSON that its check accepted`, () => { + oracleRoute(`fixed history`) let calls = 0 const payload = { toJSON: () => (calls++ === 0 ? `checked` : () => `later`), @@ -444,14 +454,24 @@ describe(`LocalStorage value fidelity oracle`, () => { }) } - fcTest.prop([historyArbitrary], { - seed: 20_261_021, + const historyFixedSeed = oracleFixedSeed(20_261_021, { numRuns: oracleRuns(300), - })(`matches the JSON value model (fixed)`, runHistory) + }) + fcTest.prop([historyArbitrary], historyFixedSeed)( + `matches the JSON value model (fixed)`, + (history) => { + oracleRoute(oracleRouteOf(historyFixedSeed)) + return runHistory(history) + }, + ) } // 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) + const historyCampaign = oraclePropertyOptions(300, property) + oracleReplayTest(fcTest, property).prop([historyArbitrary], historyCampaign)( + `matches the JSON value model (random)`, + (history) => { + oracleRoute(oracleRouteOf(historyCampaign)) + return runHistory(history) + }, + ) }) diff --git a/packages/db/tests/local-storage-write-order-oracle.test.ts b/packages/db/tests/local-storage-write-order-oracle.test.ts new file mode 100644 index 0000000000..705d9fff18 --- /dev/null +++ b/packages/db/tests/local-storage-write-order-oracle.test.ts @@ -0,0 +1,360 @@ +/** + * LocalStorage automatic writes persist in author order, over every short + * history. + * + * The law is the one that `local-storage-order-oracle.test.ts` states: within + * one Collection, a later authored write must not be replaced by an older + * handler that happens to finish later, and no later write reports persistence + * ahead of an undecided predecessor. A failed later handler still releases its + * own optimistic state promptly. That file checks two writes in a fixed set of + * schedules. This one checks every history of up to three writes, every order + * in which their handlers finish, and every accept/reject decision, and it + * checks the held-handler law after each handler finishes, not only once. + * + * Authority: the public LocalStorage guide's direct, persisted mutations; + * Collection settlement, where a fulfilled `when('settled')` reflects the + * accepted mutation; and the order law and prompt-failure rule in the header + * of `local-storage-order-oracle.test.ts`. + * + * Limits. Writes are whole-row updates and deletes of two stored rows, made + * by one Collection with automatic handlers. Inserts are excluded: an insert + * after a rejected delete of the same key is undecided behaviour. Manual + * acceptance and peer writes are outside this property because they need + * different event and ownership boundaries; the order oracle's fixed + * histories and `local-storage.peers` cover them. Storage faults and + * histories longer than three writes are also outside it. The prompt release + * of a failed later handler's receipt and optimistic row is witnessed only by + * the fixed tests in `local-storage-order.test.ts` (coverage map). + */ +import { createCollection } from '../src/collection/index' +import { createDeferred } from '../src/deferred' +import { localStorageCollectionOptions } from '../src/local-storage' +import { + initial, + makeSeededStorage, + sortedRows, +} from './local-storage-fixtures' +import { withHistoryCleanup } from './optimistic-history-oracle' +import { declareOracle } from './oracle-declaration' +import { oracleProperty, pass, violation } from './oracle-property' +import type { Row } from './local-storage-fixtures' + +// -- Histories ------------------------------------------------------------------ +// +// A history is a list of writes, the order in which their handlers finish, and +// each handler's decision. A write is legal when it is submitted: an update or +// a delete needs its row to exist in the Collection at that moment, which is +// the authored state with every earlier write applied optimistically. The +// lists cross same-key and disjoint-key writes. An update followed by a delete +// of the same row distinguishes author order from a simple last-value +// replacement: an accepted delete removes the row even when its handler +// finishes before the update's. +// +// A test is named by its history: `ua db` is an update of `a` then a delete of +// `b`, `release 10` lets write 1's handler finish before write 0's, and +// `decisions YN` accepts write 0 and rejects write 1. + +type Write = { kind: `update` | `delete`; id: `a` | `b` } +type History = { + writes: ReadonlyArray + release: ReadonlyArray + accepted: ReadonlyArray +} + +const MAX_WRITES = 3 + +function legalWriteLists(): Array> { + const lists: Array> = [] + const extend = (list: Array, present: Set) => { + if (list.length > 0) lists.push(list) + if (list.length === MAX_WRITES) return + for (const id of [`a`, `b`] as const) { + if (!present.has(id)) continue + extend([...list, { kind: `update`, id }], present) + const remaining = new Set(present) + remaining.delete(id) + extend([...list, { kind: `delete`, id }], remaining) + } + } + extend([], new Set(initial.map((row) => row.id))) + return lists +} + +function permutations(count: number): Array> { + if (count === 0) return [[]] + return permutations(count - 1).flatMap((rest) => + Array.from({ length: count }, (_, at) => [ + ...rest.slice(0, at), + count - 1, + ...rest.slice(at), + ]), + ) +} + +function decisionVectors(count: number): Array> { + if (count === 0) return [[]] + return decisionVectors(count - 1).flatMap((rest) => [ + [...rest, true], + [...rest, false], + ]) +} + +const histories: Array = legalWriteLists().flatMap((writes) => + permutations(writes.length).flatMap((release) => + decisionVectors(writes.length).map((accepted) => ({ + writes, + release, + accepted, + })), + ), +) + +// -- Model ---------------------------------------------------------------------- +// +// The model does not model handlers, optimistic overlays, storage caches, or +// sync queues. The documented effect of an accepted write, in author order: an update +// replaces the whole row with the value it authored (its position plus one), +// and a delete removes the row. A rejected write contributes nothing. The fold +// of the first m writes is what storage may hold once those m writes are +// decided, so the model returns the fold of every prefix as well as the final +// rows. + +function fold(history: History, count: number): Array { + const rows = new Map(initial.map((row) => [row.id, { ...row }])) + history.writes.slice(0, count).forEach((write, index) => { + if (!history.accepted[index]) return + if (write.kind === `update`) + rows.set(write.id, { id: write.id, value: index + 1 }) + else rows.delete(write.id) + }) + return sortedRows(rows.values()) +} + +function expectedResult(history: History) { + return { + outcomes: history.accepted, + rows: fold(history, history.writes.length), + // One fold per prefix length, from no writes to all of them. + prefixRows: Array.from({ length: history.writes.length + 1 }, (_, count) => + fold(history, count), + ), + } +} + +// -- Production driver ------------------------------------------------------------ +// +// One Collection with automatic update and delete handlers over seeded +// storage. The driver submits every write without waiting, holds each handler +// until the history releases it, and releases them in the history's order with +// the history's decision. After each release it lets two microtasks run, then +// records which receipts have settled and what storage holds. At the end it +// records each receipt's outcome, the durable and public rows, and the rows a +// fresh Collection restores. It records; it does not judge. + +function durableRows(storage: ReturnType) { + const raw = storage.getItem(`rows`) + if (raw === null) return null + const records = JSON.parse(raw) as Record + return sortedRows(Object.values(records).map(({ data }) => data)) +} + +async function drive( + history: History, + observe: (checkpoint: string, value: unknown) => void, +) { + const { writes, release, accepted } = history + const storage = makeSeededStorage() + const gates = writes.map(() => createDeferred()) + const entered = writes.map(() => createDeferred()) + // An update names itself by the value it authored. A delete names itself by + // its key: a legal history deletes a key at most once. + const deletes = new Map( + writes.flatMap((write, index) => + write.kind === `delete` ? [[write.id, index] as const] : [], + ), + ) + const hold = (index: number) => { + entered[index]!.resolve() + return gates[index]!.promise + } + type Mutations = { mutations: Array<{ modified: Row; original: Row }> } + const makeCollection = (withHandlers: boolean) => + createCollection( + localStorageCollectionOptions({ + storageKey: `rows`, + storage, + storageEventApi: { addEventListener() {}, removeEventListener() {} }, + getKey: (row) => row.id, + ...(withHandlers + ? { + onUpdate: ({ transaction }: { transaction: Mutations }) => + hold(transaction.mutations[0]!.modified.value - 1), + onDelete: ({ transaction }: { transaction: Mutations }) => + hold(deletes.get(transaction.mutations[0]!.original.id)!), + } + : {}), + }), + ) + const collection = makeCollection(true) + let reopened: ReturnType | undefined + await withHistoryCleanup( + async () => { + await collection.preload() + const transactions = writes.map((write, index) => + write.kind === `update` + ? collection.update(write.id, (draft) => { + draft.value = index + 1 + }) + : collection.delete(write.id), + ) + const settled = writes.map(() => false) + const outcomes = transactions.map((transaction, index) => + transaction.when(`settled`).then( + () => ((settled[index] = true), true), + () => ((settled[index] = true), false), + ), + ) + await Promise.all(entered.map((item) => item.promise)) + for (const [step, index] of release.entries()) { + if (accepted[index]) gates[index]!.resolve() + else gates[index]!.reject(new Error(`handler ${index} rejected`)) + await Promise.resolve() + await Promise.resolve() + observe(`held after release ${step + 1}`, { + released: release.slice(0, step + 1), + settled: [...settled], + durable: durableRows(storage), + }) + } + observe(`settled`, { + outcomes: await Promise.all(outcomes), + durable: durableRows(storage), + public: sortedRows(collection.values()), + }) + reopened = makeCollection(false) + await reopened.preload() + observe(`fresh restore`, sortedRows(reopened.values())) + }, + () => [ + () => gates.forEach((gate) => gate.resolve()), + () => reopened?.cleanup(), + () => collection.cleanup(), + ], + ) +} + +// -- Check ------------------------------------------------------------------------ +// +// After each release, the released prefix is the longest run of writes, in +// author order, whose handlers have all finished. An accepted write outside it +// must not have settled: it would report persistence ahead of an undecided +// predecessor. A rejected write may settle as soon as its own handler fails. +// Storage may lag, but it may only hold the fold of some part of the released +// prefix. At settlement, the outcomes are the decisions, and storage, the +// public rows, and a fresh restore all equal the fold of every write. + +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b) + +function checkHistory( + history: History, + observations: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +) { + const count = history.writes.length + for (const { checkpoint, value } of observations) { + if (checkpoint.startsWith(`held`)) { + const { released, settled, durable } = value as { + released: Array + settled: Array + durable: Array | null + } + let prefix = 0 + while (prefix < count && released.includes(prefix)) prefix += 1 + for (let index = prefix; index < count; index += 1) { + if (settled[index] && history.accepted[index]) + return violation( + checkpoint, + `accepted write ${index} settled while write ${prefix} was undecided`, + ) + } + const allowed = expected.prefixRows.slice(0, prefix + 1) + if (!allowed.some((rows) => same(rows, durable))) + return violation( + checkpoint, + `storage holds ${JSON.stringify(durable)}; only a fold of the first ${prefix} writes is allowed: ${JSON.stringify(allowed)}`, + ) + } else if (checkpoint === `settled`) { + const { + outcomes, + durable, + public: rows, + } = value as { + outcomes: Array + durable: Array | null + public: Array + } + if (!same(outcomes, expected.outcomes)) + return violation( + checkpoint, + `outcomes ${JSON.stringify(outcomes)}, decisions ${JSON.stringify(expected.outcomes)}`, + undefined, + { actual: outcomes, expected: expected.outcomes }, + ) + if (!same(durable, expected.rows)) + return violation( + checkpoint, + `storage ${JSON.stringify(durable)}, expected ${JSON.stringify(expected.rows)}`, + undefined, + { actual: durable, expected: expected.rows }, + ) + if (!same(rows, expected.rows)) + return violation( + checkpoint, + `public rows ${JSON.stringify(rows)}, expected ${JSON.stringify(expected.rows)}`, + undefined, + { actual: rows, expected: expected.rows }, + ) + } else if (!same(value, expected.rows)) { + return violation( + checkpoint, + `restored ${JSON.stringify(value)}, expected ${JSON.stringify(expected.rows)}`, + undefined, + { actual: value, expected: expected.rows }, + ) + } + } + // Completeness last: a history whose driver threw keeps the violation + // found at a recorded checkpoint. + const seen = new Set(observations.map(({ checkpoint }) => checkpoint)) + for (const checkpoint of [`settled`, `fresh restore`]) { + if (!seen.has(checkpoint)) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}" observation`, + ) + } + return pass +} + +// What this oracle owns: the gap report judges every function in +// src/local-storage.ts by this file's generated automatic-write histories. + +declareOracle({ + owns: [`src/local-storage.ts`], +}) + +oracleProperty({ + id: `local-storage.write-order`, + law: { + statement: `Automatic LocalStorage writes persist in author order: no accepted write settles or reaches storage ahead of an undecided predecessor, and settled storage, public rows, and a fresh restore equal the author-order fold of the accepted writes.`, + authority: `LocalStorage guide (direct, persisted mutations); Collection settlement (when('settled')); order law and prompt-failure rule in local-storage-order-oracle.test.ts`, + limits: `Up to three updates and deletes of two stored rows in one Collection; no inserts, manual acceptance, peers, or storage faults.`, + owner: `LocalStorage Collection`, + }, + histories, + label: ({ writes, release, accepted }) => + `${writes.map((write) => `${write.kind[0]}${write.id}`).join(` `)} | release ${release.join(``)} | decisions ${accepted.map((value) => (value ? `Y` : `N`)).join(``)}`, + model: expectedResult, + driver: drive, + check: checkHistory, +}) diff --git a/packages/db/tests/mutation-confirmation-oracle.test.ts b/packages/db/tests/mutation-confirmation-oracle.test.ts index f8a28f5eb8..280b1501ab 100644 --- a/packages/db/tests/mutation-confirmation-oracle.test.ts +++ b/packages/db/tests/mutation-confirmation-oracle.test.ts @@ -11,6 +11,7 @@ import { mutationConfirmationHistories, mutationConfirmationRows, } from './mutation-confirmation-oracle' +import { oracleRoute } from './oracle-config' import { declareOracle } from './oracle-declaration' import type { MutationConfirmationHistory, @@ -115,6 +116,7 @@ const controls: Array<{ test.each(controls)( `rejects wrong mutation observation: $name`, ({ applies, corrupt }) => { + oracleRoute(`calibration`) const failure = new Error(`handler failure`) const selected = histories.filter(applies) expect(selected.length).toBeGreaterThan(0) @@ -135,6 +137,7 @@ test.each(controls)( test.each([`query`, `electric`] as const)( `rejects early acknowledgement in %s`, (adapter) => { + oracleRoute(`calibration`) const failure = new Error(`handler failure`) for (const history of mutationConfirmationHistories(adapter).filter( (h) => h.confirmation === (adapter === `query` ? `refetch` : `txid`), @@ -163,6 +166,9 @@ test.each([`query`, `electric`] as const)( }, ) +// No owns: this file calibrates the shared checker with planted wrong +// observations. The adapter code it judges (Query and Electric) lives outside +// packages/db, and the model and grammar it names belong to its companion. declareOracle({ check: [checkMutationConfirmation], model: [expectedMutationConfirmation], diff --git a/packages/db/tests/mutation-handler-types-oracle.test.ts b/packages/db/tests/mutation-handler-types-oracle.test.ts index 84b18b6558..002e637980 100644 --- a/packages/db/tests/mutation-handler-types-oracle.test.ts +++ b/packages/db/tests/mutation-handler-types-oracle.test.ts @@ -7,17 +7,22 @@ * The finite grammar crosses adapter, key/scalar domain, CRUD handler, direct * Config annotation/factory call, schema presence, Query row/envelope selection, * Promise construction and return-value shape. - * An independent output/transport rule predicts admission. The actual compiler - * receives generated consumers importing public source entries. Diagnostics - * must match exactly the rejected candidate lines; missing imports or unrelated - * compiler failures never count as rejection evidence. Built declarations/npm - * resolution and actual application migrations remain separate O22 receivers. + * An independent output/transport rule predicts admission. Each type shape + * resolves to a generated consumer program, one candidate declaration per line; + * the driver is the real TypeScript compiler, which records each diagnostic. + * Diagnostics must match exactly the rejected candidate lines; missing imports + * or unrelated compiler failures never count as rejection evidence. Built + * declarations, npm resolution, and actual application migrations remain + * separate receivers (the coverage map's O22 boundary). */ -import { resolve } from 'node:path' +import { relative, resolve } from 'node:path' import ts from 'typescript' -import { afterAll, describe, expect, test } from 'vitest' -import { declareOracle } from './oracle-declaration' -import { createOracleReach } from './oracle-reach' +import { oracleProperty, pass, violation } from './oracle-property' +import type { Observation } from './oracle-property' + +// --------------------------------------------------------------------------- +// Grammar +// --------------------------------------------------------------------------- type ResultDescription = { name: string @@ -99,26 +104,27 @@ const typeShapes = ([`query`, `electric`] as const).flatMap((adapter) => ) type TypeShape = (typeof typeShapes)[number] -// Promise accepts fulfilled void/undefined and rejection. A synchronous -// never-returning function is also assignable; a synchronous void one is not. -// This rule uses the declared result description, not production handler types. -function admitsHandler(result: ResultDescription): boolean { - return ( - result.output === `never` || - (result.transport !== `sync` && result.output !== `value`) - ) -} - type Candidate = { id: string + /** The result description the candidate's handler returns. */ + result: string line: number - admitted: boolean surface: string start: number end: number handlerStart: number handlerEnd: number } + +// --------------------------------------------------------------------------- +// Resolution: a type shape becomes a consumer program +// --------------------------------------------------------------------------- +// +// The plan is the generated source and where each candidate declaration and +// its CRUD property sit in it. It holds no expected admission. + +type Plan = { shape: TypeShape; source: string; candidates: Array } + function handlerConsumer(shape: TypeShape): { source: string candidates: Array @@ -170,7 +176,7 @@ function handlerConsumer(shape: TypeShape): { candidates.push({ id, line: lines.length + 1, - admitted: admitsHandler(result), + result: result.name, surface, start, end: start + declaration.length, @@ -185,6 +191,10 @@ function handlerConsumer(shape: TypeShape): { return { source: lines.join(`\n`), candidates } } +// --------------------------------------------------------------------------- +// Production driver: the TypeScript compiler +// --------------------------------------------------------------------------- + // Only compiler mechanics are cached. Every history gets a fresh consumer AST; // all dependency diagnostics are retained. Public imports resolve to source so // stale installed declaration files cannot make this source contract pass. @@ -244,179 +254,300 @@ function compileHandlerConsumer(source: string): ReadonlyArray { return [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] } +// The driver compiles the plan's source and records every diagnostic as +// plain data: its file (the generated consumer, another file by name, or +// none), its line, span, code, and message. +type Diagnostic = { + file: string + line: number | undefined + start: number | undefined + length: number + code: number + message: string +} + +function compileDriver( + plan: Plan, + observe: (checkpoint: string, value: unknown) => void, +) { + const diagnostics = compileHandlerConsumer(plan.source).map( + (diagnostic): Diagnostic => ({ + file: + diagnostic.file === undefined + ? `(no file)` + : diagnostic.file.fileName === filename + ? `(generated consumer)` + : relative(packageRoot, diagnostic.file.fileName), + line: + diagnostic.file && diagnostic.start !== undefined + ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start) + .line + 1 + : undefined, + start: diagnostic.start, + length: diagnostic.length ?? 0, + code: diagnostic.code, + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `), + }), + ) + observe(`diagnostics`, diagnostics) +} + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- +// +// Promise accepts fulfilled void/undefined and rejection. A synchronous +// never-returning function is also assignable; a synchronous void one is not. +// This rule uses the declared result description, not production handler +// types. The model owes the set of rejected candidates, and names the states +// each shape reaches. + +function admitsHandler(result: ResultDescription): boolean { + return ( + result.output === `never` || + (result.transport !== `sync` && result.output !== `value`) + ) +} + +function admissionModel(plan: Plan) { + const admitted = new Map( + plan.candidates.map((candidate) => [ + candidate.id, + admitsHandler( + results.find((result) => result.name === candidate.result)!, + ), + ]), + ) + const { adapter, schema, data } = plan.shape + const reached = plan.candidates.map( + (candidate) => + `${adapter}/${schema ? `schema` : `plain`}/${data}/${candidate.id}`, + ) + return { admitted, reached } +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- +// // A negative candidate owes a handler-result incompatibility inside its own // declaration, anchored on its CRUD property; positive neighbors owe none. -// TypeScript may report both schema and handler overload errors for one call. Keep those distinct diagnostics, -// reject duplicates, and compare candidate membership rather than multiplicity. -// The Promise cause prevents unrelated same-code errors from substituting -// for the required rejection. The property anchor also rejects an unrelated -// Promise error inside the callback. Built diagnostics are an O22 receiver. +// TypeScript may report both schema and handler overload errors for one call. +// Keep those distinct diagnostics, reject duplicates, and compare candidate +// membership rather than multiplicity. The Promise cause prevents +// unrelated same-code errors from substituting for the required rejection. +// The property anchor also rejects an unrelated Promise error inside the +// callback. The laws: +// - `candidate-diagnostics`: every diagnostic is in the generated file, on a +// candidate's line; +// - `admission`: no admitted candidate has one, and the candidates rejected at +// their CRUD property are exactly the ones the model rejects; +// - `diagnostic-cause`: a diagnostic has the expected code, lies inside its +// candidate's declaration, and names Promise; +// - `distinct-diagnostics`: no diagnostic is reported twice. + function checkHandlerAdmission( - candidates: ReadonlyArray, - diagnostics: ReadonlyArray, -): void { - const observed = diagnostics.map((diagnostic) => { - const line = - diagnostic.file && diagnostic.start !== undefined - ? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start).line + - 1 - : undefined - const candidate = candidates.find((entry) => entry.line === line) - expect( - diagnostic.file?.fileName, - ts.flattenDiagnosticMessageText(diagnostic.messageText, `\n`), - ).toBe(filename) - expect( - candidate, - `unexpected diagnostic at line ${line}: TS${diagnostic.code} ${ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `)}`, - ).toBeDefined() - expect(candidate!.admitted, `admitted handler ${candidate!.id}`).toBe(false) - expect(candidate!.surface === `config` ? [2322] : [2322, 2769]).toContain( - diagnostic.code, + plan: Plan, + trace: ReadonlyArray, + { admitted }: ReturnType, +) { + const recorded = trace.find(({ checkpoint }) => checkpoint === `diagnostics`) + if (!recorded) + return violation( + `diagnostics`, + `the driver recorded no diagnostics`, + `trace`, ) - expect(diagnostic.start).toBeGreaterThanOrEqual(candidate!.start) - expect(diagnostic.start! + (diagnostic.length ?? 0)).toBeLessThanOrEqual( - candidate!.end, + const diagnostics = recorded.value as Array + const rejected = new Set() + for (const diagnostic of diagnostics) { + const candidate = plan.candidates.find( + (entry) => entry.line === diagnostic.line, ) - expect( - ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `), - ).toContain(`Promise`) - return { - id: candidate!.id, - handler: - diagnostic.start === candidate!.handlerStart && - diagnostic.start + (diagnostic.length ?? 0) === candidate!.handlerEnd, - diagnostic: `${diagnostic.start}/${diagnostic.length}/${diagnostic.code}/${ts.flattenDiagnosticMessageText(diagnostic.messageText, ` `)}`, - } - }) - expect(new Set(observed.map((entry) => entry.diagnostic)).size).toBe( - observed.length, - ) - expect( - [ - ...new Set( - observed.filter((entry) => entry.handler).map((entry) => entry.id), - ), - ].sort(), - ).toEqual( - candidates - .filter((entry) => !entry.admitted) - .map((entry) => entry.id) - .sort(), + if (diagnostic.file !== `(generated consumer)` || !candidate) + return violation( + `diagnostics`, + `unexpected diagnostic in ${diagnostic.file} at line ${diagnostic.line}: TS${diagnostic.code} ${diagnostic.message}`, + `candidate-diagnostics`, + ) + if (admitted.get(candidate.id)) + return violation( + `diagnostics`, + `admitted handler ${candidate.id} was rejected`, + `admission`, + ) + const codes = candidate.surface === `config` ? [2322] : [2322, 2769] + const end = (diagnostic.start ?? 0) + diagnostic.length + if ( + !codes.includes(diagnostic.code) || + diagnostic.start === undefined || + diagnostic.start < candidate.start || + end > candidate.end || + !diagnostic.message.includes(`Promise`) + ) + return violation( + `diagnostics`, + `${candidate.id}: TS${diagnostic.code} at ${diagnostic.start}..${end} (declaration ${candidate.start}..${candidate.end}): ${diagnostic.message}`, + `diagnostic-cause`, + ) + if ( + diagnostic.start === candidate.handlerStart && + end === candidate.handlerEnd + ) + rejected.add(candidate.id) + } + const texts = diagnostics.map( + (diagnostic) => + `${diagnostic.start}/${diagnostic.length}/${diagnostic.code}/${diagnostic.message}`, ) + const repeated = texts.filter((text, index) => texts.indexOf(text) !== index) + if (repeated.length > 0) + return violation( + `diagnostics`, + `${repeated.length} of ${texts.length} diagnostics were reported again: ${repeated.join(`; `)}`, + `distinct-diagnostics`, + ) + const owed = [...admitted] + .filter(([, value]) => !value) + .map(([id]) => id) + .sort() + const seen = [...rejected].sort() + if (JSON.stringify(seen) !== JSON.stringify(owed)) + return violation( + `diagnostics`, + `rejected ${JSON.stringify(seen)}, owed ${JSON.stringify(owed)}`, + `admission`, + { actual: seen, expected: owed }, + ) + return pass } -// A state name leaves out the key and scalar types, so the six shapes that -// differ only there share one state, and `minimum: 6` asks for all of them. -const states = [ - ...new Set( - typeShapes.flatMap((shape) => - surfaces.flatMap((surface) => - handlers.flatMap((handler) => - results.map( - (result) => - `${shape.adapter}/${shape.schema ? `schema` : `plain`}/${shape.data}/${surface}/${handler}/${result.name}`, - ), - ), - ), - ), - ), -] -const reach = createOracleReach(`adapter.handler-types`, states, { minimum: 6 }) -describe(`public handler result admission`, () => { - afterAll(() => { - reach.assertReached() - console.info( - `adapter.handler-types reach`, - Object.fromEntries(reach.counts()), +// --------------------------------------------------------------------------- +// Calibrations +// --------------------------------------------------------------------------- +// +// Wrong compiler observations the check must reject: a missing rejection, a +// duplicated one, a setup error in place of a rejection, an admitted handler +// reported as rejected, an unrelated error with the same code, and a +// Promise error inside a callback body instead of at the property. + +/** Replaces the recorded diagnostics, or gives undefined when `change` does. */ +const corruptDiagnostics = + ( + change: ( + diagnostics: Array, + plan: Plan, + ) => Array | undefined, + ) => + (trace: ReadonlyArray, plan: Plan) => { + const at = trace.findIndex(({ checkpoint }) => checkpoint === `diagnostics`) + if (at < 0) return undefined + const changed = change([...(trace[at]!.value as Array)], plan) + if (!changed) return undefined + return trace.map((entry, index) => + index === at ? { ...entry, value: changed } : entry, ) - }) - test.each(typeShapes)( - `admits only promised handler results: %j`, - (shape) => { - reach.beginHistory() - const consumer = handlerConsumer(shape) - const diagnostics = compileHandlerConsumer(consumer.source) - checkHandlerAdmission(consumer.candidates, diagnostics) - for (const candidate of consumer.candidates) - reach.reach( - `${shape.adapter}/${shape.schema ? `schema` : `plain`}/${shape.data}/${candidate.id}`, - ) - }, - 30000, - ) -}) + } -// Controls alter actual compiler observations, not the expected grammar. They -// first establish that the generated neighbor is admitted/rejected correctly. -test.each([ - `missing-rejection`, - `duplicate-rejection`, - `setup-error`, - `reject-void`, - `unrelated-same-code`, - `unrelated-promise-in-body`, -] as const)( - `rejects wrong handler compiler observation: %s`, - (fault) => { - const consumer = handlerConsumer(typeShapes[0]!) - const diagnostics = [...compileHandlerConsumer(consumer.source)] - checkHandlerAdmission(consumer.candidates, diagnostics) - if (fault === `missing-rejection`) { - const line = diagnostics[0]!.file!.getLineAndCharacterOfPosition( - diagnostics[0]!.start!, - ).line - for (let i = diagnostics.length - 1; i >= 0; i--) - if ( - diagnostics[i]!.file!.getLineAndCharacterOfPosition( - diagnostics[i]!.start!, - ).line === line - ) - diagnostics.splice(i, 1) - } - if (fault === `duplicate-rejection`) diagnostics.push(diagnostics[0]!) - if (fault === `setup-error`) - diagnostics[0] = { ...diagnostics[0]!, code: 2307 } - if (fault === `unrelated-same-code`) - diagnostics[0] = { - ...diagnostics[0]!, - code: 2322, - messageText: `Type 'number' is not assignable to type 'string'.`, - } - if (fault === `unrelated-promise-in-body`) { - const candidate = consumer.candidates.find( - (entry) => - entry.line === - diagnostics[0]!.file!.getLineAndCharacterOfPosition( - diagnostics[0]!.start!, - ).line + - 1, - )! - diagnostics[0] = { - ...diagnostics[0]!, - start: candidate.handlerEnd + 3, - length: 1, - } - } - if (fault === `reject-void`) { - const admitted = consumer.candidates.find( - (candidate) => candidate.admitted, - )! - diagnostics.push({ - ...diagnostics[0]!, - start: admitted.handlerStart, - length: admitted.handlerEnd - admitted.handlerStart, - }) - } - expect(() => - checkHandlerAdmission(consumer.candidates, diagnostics), - ).toThrow() +const handlerCalibrations = [ + { + name: `missing rejection`, + law: `admission`, + corrupt: corruptDiagnostics((diagnostics) => { + const first = diagnostics[0] + return first + ? diagnostics.filter((diagnostic) => diagnostic.line !== first.line) + : undefined + }), }, - 30000, -) + { + name: `duplicate rejection`, + law: `distinct-diagnostics`, + corrupt: corruptDiagnostics((diagnostics) => + diagnostics[0] ? [...diagnostics, diagnostics[0]] : undefined, + ), + }, + { + name: `setup error`, + law: `diagnostic-cause`, + corrupt: corruptDiagnostics((diagnostics) => + diagnostics[0] + ? [{ ...diagnostics[0], code: 2307 }, ...diagnostics.slice(1)] + : undefined, + ), + }, + { + name: `rejected void`, + law: `admission`, + corrupt: corruptDiagnostics((diagnostics, plan) => { + const model = admissionModel(plan) + const candidate = plan.candidates.find((entry) => + model.admitted.get(entry.id), + ) + return diagnostics[0] && candidate + ? [ + ...diagnostics, + { + ...diagnostics[0], + line: candidate.line, + start: candidate.handlerStart, + length: candidate.handlerEnd - candidate.handlerStart, + }, + ] + : undefined + }), + }, + { + name: `unrelated same code`, + law: `diagnostic-cause`, + corrupt: corruptDiagnostics((diagnostics) => + diagnostics[0] + ? [ + { + ...diagnostics[0], + code: 2322, + message: `Type 'number' is not assignable to type 'string'.`, + }, + ...diagnostics.slice(1), + ] + : undefined, + ), + }, + { + name: `Promise error inside the callback body`, + law: `admission`, + corrupt: corruptDiagnostics((diagnostics, plan) => { + const first = diagnostics[0] + const candidate = + first && plan.candidates.find((entry) => entry.line === first.line) + return first && candidate + ? [ + { ...first, start: candidate.handlerEnd + 3, length: 1 }, + ...diagnostics.slice(1), + ] + : undefined + }), + }, +] -declareOracle({ - check: [checkHandlerAdmission], - model: [admitsHandler], - grammar: [handlerConsumer, typeShapes, results], +// No owns: the handler types this property judges belong to the Query and +// Electric adapter packages, outside packages/db. The property's own parts +// (the compiler driver and the admission model) are declared by oracleProperty. +oracleProperty({ + id: `adapter.handler-types`, + law: { + statement: `Query and Electric handlers admit exactly the results that Promise accepts: a handler that returns a value, or a synchronous handler that returns, is rejected at its CRUD property with a Promise diagnostic, and no other diagnostic appears.`, + authority: `the approved RC handler migration and the public QueryCollectionConfig and ElectricCollectionConfig types`, + limits: `Source entries only; built declarations, npm resolution, and application migrations are separate receivers.`, + owner: `RC public mutation-handler type admission`, + }, + histories: typeShapes, + label: ({ adapter, key, scalar, schema, data }) => + `${adapter} ${key} ${scalar} ${schema ? `schema` : `plain`} ${data}`, + resolve: (shape: TypeShape): Plan => ({ shape, ...handlerConsumer(shape) }), + model: admissionModel, + reached: (_plan, _trace, expected) => expected.reached, + driver: compileDriver, + check: checkHandlerAdmission, + calibrations: handlerCalibrations, }) diff --git a/packages/db/tests/notification-laws-oracle.test.ts b/packages/db/tests/notification-laws-oracle.test.ts index f09ec97a4d..f2519f4a63 100644 --- a/packages/db/tests/notification-laws-oracle.test.ts +++ b/packages/db/tests/notification-laws-oracle.test.ts @@ -23,14 +23,38 @@ import { expectNotificationsStopped, } from '../../db-collection-e2e/src/utils/notification-laws-oracle' import { ScenarioLifetime } from './conformance/scenario-lifetime' -import { oracleRuns } from './oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config' +import { declareOracle } from './oracle-declaration' import type { Notification } from '../../db-collection-e2e/src/utils/notification-laws-oracle' type Row = { id: string; count: number } +// What this oracle owns: the gap report judges every function in the +// Collection change-message path (subscribeChanges) by this file's generated +// publication histories and the calibration of its Map checker. +declareOracle({ + owns: [`src/collection/changes.ts#CollectionChangesManager.subscribeChanges`], +}) + it.each([20260913, undefined])( `checks real query publications against generated worlds seed=%s`, async (seed) => { + const parameters = { + ...(seed === undefined + ? oraclePropertyOptions(30, `notification-laws.query-publications`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(30) })), + examples: [ + [1, [2, null]], + [1, [null]], + ], + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.option(fc.integer(), { nil: null }), @@ -109,14 +133,7 @@ it.each([20260913, undefined])( }) }, ), - { - seed, - numRuns: oracleRuns(30), - examples: [ - [1, [2, null]], - [1, [null]], - ], - }, + parameters, ) }, ) @@ -124,6 +141,12 @@ it.each([20260913, undefined])( it.each([20260913, undefined])( `reconstructs generated publication histories seed=%s`, (seed) => { + const campaignParameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, `notification-laws.publication-histories`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), + } + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property( fc.array(fc.dictionary(fc.constantFrom('a', 'b', 'c'), fc.integer()), { @@ -177,7 +200,7 @@ it.each([20260913, undefined])( ).toThrow() }, ), - { seed, numRuns: oracleRuns(100) }, + campaignParameters, ) }, ) @@ -192,6 +215,7 @@ it.each([ 'absent-delete', 'unknown-type', ])(`rejects the %s publication mutant`, (fault) => { + oracleRoute(`calibration`) const peer = { id: 'peer', count: 2 } const row = { id: 'owned', count: 1 } const insert = { type: 'insert', key: row.id, value: row } @@ -219,6 +243,7 @@ it.each([ it.each([20260913, undefined])( `rejects corrupt prior values and repeated updates seed=%s`, (seed) => { + oracleRoute(`calibration`) fc.assert( fc.property(fc.integer({ min: -1000, max: 1000 }), (count) => { const old = { id: 'owned', count } @@ -251,7 +276,11 @@ it.each([20260913, undefined])( expectNotificationHistory([], [...callbacks, callbacks[1]!], [next]), ).toThrow() }), - { seed, numRuns: oracleRuns(50) }, + { + ...(seed === undefined + ? oraclePropertyOptions(50, `notification-laws.corrupt-prior-values`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(50) })), + }, ) }, ) diff --git a/packages/db/tests/optimistic-composition-oracle.test.ts b/packages/db/tests/optimistic-composition-oracle.test.ts index e80e5973a0..3b0f1da176 100644 --- a/packages/db/tests/optimistic-composition-oracle.test.ts +++ b/packages/db/tests/optimistic-composition-oracle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { oracleRoute } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { stripVirtualProps } from './utils.js' import { @@ -72,12 +73,23 @@ function expectVisibleRow( expect(stripVirtualProps(collection.get(1))).toEqual(expectedRow) } -declareOracle({ check: [expectVisibleRow], model: [expectedOwnedRow] }) +// What this oracle owns: the gap report judges the optimistic row overlay in +// CollectionStateManager.recomputeOptimisticState and the transaction +// mutate/rollback path by this file's one-key matrix. +declareOracle({ + owns: [ + `src/collection/state.ts#CollectionStateManager.recomputeOptimisticState`, + `src/transactions.ts`, + ], + check: [expectVisibleRow], + model: [expectedOwnedRow], +}) describe(`optimistic snapshot ownership`, () => { it.each(cases)( `$order / $phase / rollback $removed / $fields`, async ({ order, phase, removed, fields }) => { + oracleRoute(`case`) const fixture = source() const pending = [ pendingTransaction(), diff --git a/packages/db/tests/optimistic-history-oracle.property.test.ts b/packages/db/tests/optimistic-history-oracle.property.test.ts new file mode 100644 index 0000000000..b4c32e59de --- /dev/null +++ b/packages/db/tests/optimistic-history-oracle.property.test.ts @@ -0,0 +1,260 @@ +/** + * # Optimistic histories, as one property + * + * The laws are the ones the optimistic-history module states: an optimistic + * mutation is visible in the tick that makes it; a sync transaction committed + * while a mutation persists is accepted and queued, and applies in the + * publication that drops the settling mutation's optimistic state; a held + * completed row lives only while a committed sync transaction that has not + * applied touches its key; attribution, truncate, external completion, and + * open source transactions behave as the module header describes. Each law the + * check judges is named in `checkOptimisticHistory`. + * + * This property runs the core grammar (`optimisticHistory`) through the core + * production path. The history resolves to a plan of concrete actions; the + * driver records what production published and showed; the model owes, at + * each checkpoint, the allowed cuts, rows, outcomes, receipts, and tracked + * transactions. The seven observation mutants are calibrations. + * + * Limits: the core grammar has no partial-update production path, schema default, + * pending manual edit, external completion, rejected handler, reentrant drain, + * or ordered source operations. Those dimensions are exercised by the owners + * that still drive `runOptimisticHistory` with fixed histories (coverage map). + */ +import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-property.js' +import { + checkOptimisticHistory, + optimisticHistory, + optimisticHistoryCalibrations, + optimisticHistoryDriver, + optimisticHistoryModel, + optimisticHistoryReach, + optimisticHistoryStates, + resolveOptimisticHistory, +} from './optimistic-history-oracle.js' +import type { + HistoryRow, + OptimisticHistory, + OptimisticStep, +} from './optimistic-history-oracle.js' + +const row: HistoryRow = { id: 1, a: 0, b: 0, c: 0 } +const edit = ( + fields: Partial>, + optimistic = true, +): OptimisticStep => ({ + type: `edit`, + key: 1, + fields, + optimistic, + inHandler: undefined, +}) +const settle = (slot: number, success = true): OptimisticStep => ({ + type: `settle`, + slot, + success, + cascade: false, +}) +const sync = ( + rows: Array, + truncate = false, + copies = 1, +): OptimisticStep => ({ type: `sync`, rows, deletes: [], truncate, copies }) + +// Regressions from the settlement-boundary owner, written in the grammar's +// own form so that they shrink like generated histories. +const cases: Array<{ + name: string + initial: Array + steps: Array +}> = [ + { + name: `one confirmation for repeated queued writes`, + initial: [row], + steps: [edit({ a: 1 }), sync([{ ...row, a: 1 }], false, 2), settle(0)], + }, + { + name: `an acknowledged insert cannot remove an accepted update`, + initial: [], + steps: [ + edit({ a: 1 }), + sync([{ ...row, a: 1 }], false), + edit({ b: 2 }), + settle(1), + settle(0, false), + ], + }, + { + name: `failed insertion cannot remove later same-key snapshots`, + initial: [], + steps: [ + edit({ a: 1 }), + edit({ b: 1 }), + settle(1), + settle(0, false), + edit({ a: 2 }), + edit({ b: 2 }), + settle(1), + settle(0), + ], + }, + ...[true, false].map((success) => ({ + name: `dependent snapshot follows insert settlement: ${success}`, + initial: [], + steps: [edit({ a: 1 }), edit({ b: 2 }), settle(1), settle(0, success)], + })), + ...[true, false].map((truncate) => ({ + name: `failed birth does not erase accepted sibling attribution: ${truncate}`, + initial: [], + steps: [ + edit({ a: 1 }), + edit({ b: 2 }), + settle(1), + settle(0, false), + sync([row], truncate), + ], + })), + { + name: `a later accepted update survives an earlier failed insertion`, + initial: [], + steps: [edit({ a: 1 }), edit({ b: 2 }), settle(0, false), settle(0)], + }, + ...[true, false].map((truncate) => ({ + name: `sync retirement precedes rebuilding active snapshots: ${truncate}`, + initial: [], + steps: [ + edit({ a: 1 }), + edit({ b: 2 }), + settle(1), + sync([], truncate), + settle(0, false), + ], + })), + { + name: `unchanged persistence completion does not publish`, + initial: [row], + steps: [edit({ a: 1 }), settle(0)], + }, + { + name: `truncate preserves the captured whole row`, + initial: [row], + steps: [edit({ a: 1 }), sync([{ ...row, b: 2 }], true), settle(0)], + }, + { + name: `rollback does not rewrite a later captured request`, + initial: [row], + steps: [edit({ a: 1 }), edit({ b: 2 }), settle(0, false), settle(0)], + }, + { + name: `duplicate writes retain a local acknowledgement within a batch`, + initial: [], + steps: [edit({ a: 1 }, false), sync([{ ...row, a: 1 }], false, 2)], + }, + { + name: `a persisting peer does not hide rollback publication`, + initial: [], + steps: [edit({ a: 1 }, false), edit({ a: 2 }), sync([]), settle(1, false)], + }, + { + name: `truncate retains pending nonoptimistic attribution`, + initial: [], + steps: [ + edit({ a: 1 }, false), + sync([], true), + sync([{ ...row, a: 1 }], false), + ], + }, + { + name: `completed sibling does not erase active truncate attribution`, + initial: [], + steps: [ + edit({ a: 1 }, false), + edit({ a: 2 }), + settle(1), + sync([{ ...row, a: 1 }], true), + ], + }, + { + name: `removal of a truncate-retained snapshot reaches subscribers`, + initial: [], + steps: [edit({ a: 1 }), settle(0), sync([], true), sync([])], + }, + // The seed-1 random campaign's shrunk counterexample, found when the + // model compared visible rows through JSON, which erases a zero's sign. + // The rolled-back flipZeros edit turns the row's zeros back from 0 to -0, + // a visible change (-0 and 0 are distinct row values, maintainer + // decision 2026-10-29), so the settlement is not quiet. + { + name: `a rollback that only flips a zero's sign publishes`, + initial: [], + steps: [ + { + type: `edit`, + key: 3, + fields: { c: -0 }, + optimistic: true, + inHandler: { + type: `sync`, + rows: [], + deletes: [], + truncate: false, + copies: 1, + awaitReceipt: false, + }, + }, + { + type: `edit`, + key: 3, + fields: { a: 0, b: 0, c: 0 }, + optimistic: true, + inHandler: undefined, + flipZeros: true, + }, + settle(1, false), + { + type: `edit`, + key: 1, + fields: { c: -0 }, + optimistic: false, + inHandler: undefined, + }, + settle(0, false), + ], + }, +] + +// What this oracle owns: the gap report judges every function in +// src/transactions.ts and src/collection/state.ts by this file's generated +// optimistic and sync histories. + +declareOracle({ + owns: [`src/transactions.ts`, `src/collection/state.ts`], +}) + +oracleProperty({ + id: `optimistic-history.core`, + law: { + statement: `Optimistic and sync histories expose the rows, publications, request outcomes, receipts, and tracked transactions that the optimistic-history model owes at every step.`, + authority: `packages/db/tests/optimistic-history-oracle.ts header (the optimistic and sync history contract); the public transaction contract for commit admission and terminal outcomes`, + limits: `The core grammar and production path only: no partial-update production path, schema default, pending manual edit, external completion, rejected handler, reentrant drain, ordered source operations, acknowledged handler write, or wrapping sync.`, + owner: `Optimistic state`, + }, + histories: { + arbitrary: optimisticHistory, + states: [], + measured: optimisticHistoryStates, + runs: 100, + fixedSeeds: [86103], + fixed: cases.map(({ name, initial, steps }) => ({ + name, + history: { initial, steps } satisfies OptimisticHistory, + })), + }, + resolve: resolveOptimisticHistory, + model: optimisticHistoryModel, + reached: optimisticHistoryReach, + driver: optimisticHistoryDriver(), + check: checkOptimisticHistory, + calibrations: optimisticHistoryCalibrations, +}) diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index 90e21f885e..f11137b4b9 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -8,6 +8,7 @@ import { createLiveQueryCollection } from '../src/query/index.js' import { whenSyncAccepted } from '../src/sync-receipt.js' import { createTransaction } from '../src/transactions.js' import { declareOracle } from './oracle-declaration.js' +import { forwardCut, pass, violation, withinTurns } from './oracle-property.js' import type { CollectionConfig, SyncConfig } from '../src/types.js' /** @@ -64,7 +65,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * sync transactions the Collection has taken, `deferred` holds committed sync * transactions waiting behind a wrapping sync's held durable step, and `base` * is the applied synced rows. `deferred` is model vocabulary; core reaches it - * only through a wrapping sync, so the core lane never enters it. `held` is + * only through a wrapping sync, so the core production path never enters it. `held` is * a model-only flag for the hold described above: it is set when a * transaction completes while a queued sync transaction touches its key, and * the drain that applies that queue clears it. @@ -92,7 +93,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * A persisting or different-key peer keeps its own settlement. Handler rejection * cascades; an explicit rollback can request a secondary rollback without it. * Ordinary source batches write rows before deletes. The ordered-operations - * lane also permits a delete and reinsert of one key in the same transaction. + * grammar also permits a delete and reinsert of one key in the same transaction. * Its `row` action combines source insert and update; the production driver * resolves that kind against source membership before invoking sync writes. * A reentrant history commits a truncate and its suffix from a subscriber @@ -125,7 +126,7 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * transaction does not need to stay tracked for. * * `runOptimisticHistory` gives the same edit, delete, settle, and sync history - * to this model and a real Collection. A `WrappingSyncLane` runs the same + * to this model and a real Collection. A `WrappingSyncPath` runs the same * history through a wrapping sync, adds `hold-store` and `release-store` * steps, and lets the wrapping sync settle before each comparison. Right after * each optimistic mutation call, before any await, the driver checks that the @@ -153,7 +154,7 @@ type SourceBatch = { operations?: Array | undefined truncate: boolean copies: number - // In the partial-update lane, the source's updates omit `c`. + // In the partial-update production path, the source's updates omit `c`. partial?: boolean | undefined } // A transaction with no operation and no truncate has no durable write. @@ -539,8 +540,14 @@ const observed = ( const sorted = (rows: Iterable) => [...rows].sort((a, b) => a.id - b.id) -/** Validate native deltas before their kind/old value are lost to Map.set. */ -export function expectHistoryEventSemantics( +/** + * Why a batch of native changes is invalid against the replica it applies + * to, or undefined when it is valid: an insert names an absent key, an update + * names a present key and carries its previous visible row, and a delete + * names a present key and carries the removed row. Validated before a + * change's kind and old value are lost to Map.set. + */ +export function eventSemanticsDifference( before: ReadonlyMap, changes: ReadonlyArray<{ key: unknown @@ -548,36 +555,43 @@ export function expectHistoryEventSemantics( value: T previousValue?: T }>, - label: string, -): void { +): string | undefined { const rows = new Map(before) for (const change of changes) { - const message = `${label}: event semantics ${change.type} ${String(change.key)}` + const event = `${change.type} ${String(change.key)}` if (change.type === `insert`) { - expect(rows.has(change.key), `${message}: absent old membership`).toBe( - false, - ) + if (rows.has(change.key)) return `${event}: the key is already present` rows.set(change.key, change.value) - } else { - expect(rows.has(change.key), `${message}: present old membership`).toBe( - true, - ) - const previous = rows.get(change.key) - if (change.type === `update`) { - expect( - change.previousValue, - `${message}: previous visible row`, - ).toStrictEqual(previous) - rows.set(change.key, change.value) - } else { - expect(change.type, message).toBe(`delete`) - expect(change.value, `${message}: removed visible row`).toStrictEqual( - previous, - ) - rows.delete(change.key) - } + continue } + if (!rows.has(change.key)) return `${event}: the key is absent` + const previous = rows.get(change.key) + if (change.type === `update`) { + if (!isDeepStrictEqual(change.previousValue, previous)) + return `${event}: previous value ${JSON.stringify(change.previousValue)}, visible row ${JSON.stringify(previous)}` + rows.set(change.key, change.value) + } else if (change.type === `delete`) { + if (!isDeepStrictEqual(change.value, previous)) + return `${event}: removed value ${JSON.stringify(change.value)}, visible row ${JSON.stringify(previous)}` + rows.delete(change.key) + } else return `${event}: unknown change type` } + return undefined +} + +/** Validate native deltas before their kind/old value are lost to Map.set. */ +export function expectHistoryEventSemantics( + before: ReadonlyMap, + changes: ReadonlyArray<{ + key: unknown + type: string + value: T + previousValue?: T + }>, + label: string, +): void { + const difference = eventSemanticsDifference(before, changes) + expect(difference, `${label}: event semantics`).toBeUndefined() } export type HistoryOutcome = @@ -681,6 +695,13 @@ export const historyRow = fc.record({ b: fieldValue, c: fieldValue, }) +const noDeletes = fc.constant(0).map( + (): Array => [], + (value) => { + if (Array.isArray(value) && value.length === 0) return 0 + throw new Error(`not an empty delete list`) + }, +) const sourceBatch = fc.record({ type: fc.constant(`sync` as const), rows: fc.uniqueArray(historyRow, { @@ -689,7 +710,9 @@ const sourceBatch = fc.record({ }), // The source may also delete keys, including keys it does not hold. deletes: fc.oneof( - { weight: 3, arbitrary: fc.constant([]) }, + // A mapped constant, not `fc.constant([])`: fast-check compares a constant + // by identity, so a fixed `[]` would not be a member of the language. + { weight: 3, arbitrary: noDeletes }, { weight: 1, arbitrary: fc.uniqueArray(fc.integer({ min: 1, max: 3 }), { @@ -703,13 +726,24 @@ const sourceBatch = fc.record({ }) // A mutation handler may write a source batch before it returns, and may // await that batch's commit receipt. +type SourceBatchValue = + typeof sourceBatch extends fc.Arbitrary ? Value : never const handlerSourceBatch = fc.oneof( { weight: 3, arbitrary: fc.constant(undefined) }, { weight: 1, - arbitrary: fc - .tuple(sourceBatch, fc.boolean()) - .map(([batch, awaitReceipt]) => ({ ...batch, awaitReceipt })), + arbitrary: fc.tuple(sourceBatch, fc.boolean()).map( + ([batch, awaitReceipt]) => ({ ...batch, awaitReceipt }), + // So a fixed handler batch is a member of the language. + (value) => { + const { awaitReceipt, ...batch } = value as { + awaitReceipt?: unknown + } + if (typeof awaitReceipt !== `boolean`) + throw new Error(`not a handler source batch`) + return [batch as SourceBatchValue, awaitReceipt] + }, + ), }, ) export const optimisticStep: fc.Arbitrary = fc.oneof( @@ -747,7 +781,16 @@ export const optimisticStep: fc.Arbitrary = fc.oneof( type: fc.constant(`edit` as const), key: fc.integer({ min: 1, max: 3 }), // With no zero to flip, the edit writes zeros, so a later one can. - fields: fc.constant({ a: 0, b: 0, c: 0 }), + // A mapped constant, not an object constant: fast-check compares a + // constant by identity, so a fixed flip edit would not be a member of + // the language. + fields: fc.constant(0).map( + () => ({ a: 0, b: 0, c: 0 }), + (value) => { + if (isDeepStrictEqual(value, { a: 0, b: 0, c: 0 })) return 0 + throw new Error(`not the zeros of a flip edit`) + }, + ), optimistic: fc.boolean(), inHandler: fc.constant(undefined), flipZeros: fc.constant(true), @@ -788,17 +831,22 @@ export const optimisticHistory = fc.record({ steps: fc.array(optimisticStep, { minLength: 2, maxLength: 24 }), }) // The core grammar plus store steps. While a hold is outstanding, an -// in-handler batch does not await acceptance. +// in-handler batch does not await acceptance. Every mapped piece has an +// unmapper, and no step is an object constant (fast-check compares those by +// identity), so a fixed or constructed history is a member of the language. // A deferred transaction's signal aborts before its turn. -const abortStep: fc.Arbitrary = fc - .nat(3) - .map((slot) => ({ type: `abort-deferred` as const, slot })) +const abortStep: fc.Arbitrary = fc.record({ + type: fc.constant(`abort-deferred` as const), + slot: fc.nat(3), +}) // Holds outnumber releases, so a hold often spans several settlements. -const storeStep: fc.Arbitrary = fc.constantFrom( - { type: `hold-store` as const }, - { type: `hold-store` as const }, - { type: `release-store` as const }, -) +const storeStep: fc.Arbitrary = fc.record({ + type: fc.constantFrom( + `hold-store` as const, + `hold-store` as const, + `release-store` as const, + ), +}) function excludeHeldAcceptance( steps: Array, ): Array { @@ -818,35 +866,60 @@ function excludeHeldAcceptance( // A handler that commits its own key's server row and returns at once, the // way a source acknowledges a write when it commits it. The shared grammar's // handler batches rarely write their own key. -const acknowledgedEdit: fc.Arbitrary = fc - .tuple(historyRow, fc.boolean(), fc.boolean()) - .map(([row, isDelete, optimistic]) => - isDelete - ? { - type: `delete` as const, - key: row.id, - optimistic, - inHandler: { - type: `sync` as const, - rows: [], - deletes: [row.id], - truncate: false, - copies: 1, - }, - } - : { - type: `edit` as const, - key: row.id, - fields: { a: row.a, b: row.b, c: row.c }, - optimistic, - inHandler: { - type: `sync` as const, - rows: [row], - truncate: false, - copies: 1, - }, - }, - ) +const acknowledgedEditStep = ([row, optimistic]: [HistoryRow, boolean]) => ({ + type: `edit` as const, + key: row.id, + fields: { a: row.a, b: row.b, c: row.c }, + optimistic, + inHandler: { + type: `sync` as const, + rows: [row], + truncate: false, + copies: 1, + }, +}) +const acknowledgedDeleteStep = ([key, optimistic]: [number, boolean]) => ({ + type: `delete` as const, + key, + optimistic, + inHandler: { + type: `sync` as const, + rows: [], + deletes: [key], + truncate: false, + copies: 1, + }, +}) +// An unmapper rebuilds the step from its source and accepts only an exact match. +function unmapping( + build: (source: Source) => OptimisticStep, + source: (step: OptimisticStep) => Source, +) { + return (value: unknown): Source => { + const recovered = source(value as OptimisticStep) + if (!isDeepStrictEqual(build(recovered), value)) + throw new Error(`not an acknowledged handler write`) + return recovered + } +} +const acknowledgedEdit: fc.Arbitrary = fc.oneof( + fc.tuple(historyRow, fc.boolean()).map( + acknowledgedEditStep, + unmapping(acknowledgedEditStep, (step) => [ + (step.type === `edit` && step.inHandler?.type === `sync` + ? step.inHandler.rows[0] + : undefined) as HistoryRow, + step.type === `edit` && step.optimistic, + ]), + ), + fc.tuple(fc.integer({ min: 1, max: 3 }), fc.boolean()).map( + acknowledgedDeleteStep, + unmapping(acknowledgedDeleteStep, (step) => [ + step.type === `delete` ? step.key : 0, + step.type === `delete` && step.optimistic, + ]), + ), +) const settleAll: Array = Array.from({ length: 24 }, () => ({ type: `settle` as const, slot: 0, @@ -864,8 +937,15 @@ const observeEveryKey: Array = [ copies: 1, }, ] +const wrappingTail = [...settleAll, ...observeEveryKey] +/** A wrapping-sync history's steps: its body, then the settle-and-observe tail. */ +export function wrappingSteps( + body: Array, +): Array { + return [...excludeHeldAcceptance(body), ...wrappingTail] +} /** - * The grammar for a wrapping-sync lane: the core grammar plus held durable + * The grammar for a wrapping-sync production path: the core grammar plus held durable * writes and acknowledged edits. */ export const wrappingSyncHistory = fc.record({ @@ -886,11 +966,13 @@ export const wrappingSyncHistory = fc.record({ // Settle every outstanding transaction before the release, so an // outstanding hold meets a drain. A settle with nothing active is skipped. // Then observe every key through one later source transaction. - .map((steps) => [ - ...excludeHeldAcceptance(steps), - ...settleAll, - ...observeEveryKey, - ]), + .map(wrappingSteps, (value) => { + const steps = value as Array + const body = steps.slice(0, steps.length - wrappingTail.length) + if (!isDeepStrictEqual(wrappingSteps(body), steps)) + throw new Error(`not a wrapping-sync step list`) + return body + }), }) /** @@ -900,7 +982,7 @@ export const wrappingSyncHistory = fc.record({ * comparison. It can also hold a durable step, which is the only way this * grammar reaches the model's `deferred` state. */ -export type WrappingSyncLane = { +export type WrappingSyncPath = { wrap: (config: CollectionConfig) => CollectionConfig // Hold the durable step of the next source transaction that writes. holdNextStore: () => void @@ -930,13 +1012,13 @@ export async function runOptimisticHistory( | `previous-value` | `update-as-insert` | `retained-default` - // Writes the partial lane's rows under `rowUpdateMode: 'full'`, so the + // Writes the partial-update production path's rows under `rowUpdateMode: 'full'`, so the // Collection replaces the row instead of merging it. | `partial-as-full`, options: { insertDefault?: number partialUpdates?: boolean - wrapping?: WrappingSyncLane + wrapping?: WrappingSyncPath } = {}, ) { const partialUpdates = options.partialUpdates === true @@ -948,7 +1030,7 @@ export async function runOptimisticHistory( whenSyncAccepted, } if (wrapping && options.insertDefault !== undefined) - throw new Error(`The wrapping-sync lane has no schema lane`) + throw new Error(`The wrapping-sync production path has no schema grammar`) const model = new HistoryModel(initial, options.insertDefault, partialUpdates) let sync!: Parameters[`sync`]>[0] let starting: ReturnType> | undefined @@ -973,7 +1055,7 @@ export async function runOptimisticHistory( onUpdate: handler, onDelete: handler, sync: { - // The partial-update lane keeps the default row update mode. + // The partial-update production path keeps the default row update mode. ...(partialUpdates && mutant !== `partial-as-full` ? {} : { rowUpdateMode: `full` as const }), @@ -1131,7 +1213,7 @@ export async function runOptimisticHistory( beginSourceBatch(step) return commitSourceBatch(step) } - // In a wrapping-sync lane each source transaction carries its own signal, + // In a wrapping-sync production path each source transaction carries its own signal, // so a history can abort it while it is deferred. const controllers = new Map() function commitSourceBatch(step: SourceBatch) { @@ -1326,9 +1408,11 @@ export async function runOptimisticHistory( change.value.id, ) } - const next = cuts.findIndex( - (cut, index) => - index >= cutIndex && isDeepStrictEqual(cut, publication.replica), + const next = forwardCut( + cuts, + publication.replica, + cutIndex, + isDeepStrictEqual, ) expect( next, @@ -1622,11 +1706,11 @@ export async function runOptimisticHistory( op.tx.rollback({ isSecondaryRollback: !step.cascade }) op.done.resolve() } - // A receipt that waited for visibility would deadlock the handler. - await Promise.race([ - op.outcome.settled, - new Promise((resolve) => setTimeout(resolve, 20)), - ]) + // A receipt that waited for visibility would deadlock the handler, + // so the wait is bounded. Settlement arrives within microtasks in + // every history (1,037 settlements, none after the first turn), so + // the bound counts event-loop turns, not milliseconds. + await withinTurns(op.outcome.settled, 10) await Promise.resolve() expect( op.settledRows, @@ -1650,7 +1734,9 @@ export async function runOptimisticHistory( counts.abortedDeferred++ } else if (step.type === `reentrant`) { if (wrapping) - throw new Error(`The wrapping-sync lane has no reentrant drain`) + throw new Error( + `The wrapping-sync production path has no reentrant drain`, + ) if (open) continue if (!step.trigger.truncate) throw new Error(`Reentrant trigger must publish a truncate`) @@ -1782,3 +1868,1541 @@ export async function runOptimisticHistory( } declareOracle({ check: [runOptimisticHistory], model: [HistoryModel] }) + +// =========================================================================== +// The optimistic history as oracleProperty parts +// =========================================================================== +// +// The same laws, model, and production paths as `runOptimisticHistory`, with +// the jobs separated (rubric, ORC-003): +// +// - The history is the initial rows, the steps, and the production path's options. +// - `resolveOptimisticHistory` turns it into a plan of concrete actions. A +// step can name its target indirectly, such as "settle active transaction +// `slot`", and an edit can be a no-op; resolution replays the model's +// state to decide that, so it is part of what the history means, not an +// expectation. The plan holds only actions. +// - `optimisticHistoryModel` replays the same model over the plan and owes, +// at each checkpoint, the allowed publication cuts, the visible rows, each +// request's outcome and state, the receipts that must still wait, and the +// unsettled transactions. +// - `optimisticHistoryDriver(productionPath)` runs the plan through a real Collection +// and records, at each checkpoint, what production published and showed. +// It decides nothing. +// - `checkOptimisticHistory` compares them, law by law. + +/** One history: rows, steps, and the production path's row options. */ +export type OptimisticHistory = { + initial: Array + steps: ReadonlyArray + /** The default `partial` row update mode, so a source update may omit `c`. */ + partialUpdates?: boolean + /** A schema default for `c` on an insert that omits it. */ + insertDefault?: number +} + +/** A source transaction and the message kind of each of its operations. */ +type PlannedBatch = { + batch: SourceBatch + /** `insert` or `update` for a row, by operation index. */ + rowKinds: Map + /** Operation indexes of deletes of keys the source did not hold. */ + absentDeletes: Set + /** Partial updates whose omitted `c` differs from the source's held row. */ + distinguishingPartials: number +} + +type PlanAction = + | { + type: `edit` + position: number + step: Extract + /** The request's index among the history's requests. */ + index: number + kind: `insert` | `update` | `delete` + /** The row the request authors. */ + row: HistoryRow + manual: boolean + handler?: PlannedBatch & { awaitReceipt: boolean } + } + | { type: `complete`; position: number; index: number } + | { + type: `settle` + position: number + index: number + success: boolean + failure: `rollback` | `reject` | undefined + cascade: boolean + } + | { type: `hold-store`; position: number } + | { type: `release-store`; position: number | `final` } + | { type: `abort-deferred`; position: number; batch: SourceBatch } + | { + type: `reentrant` + position: number + trigger: PlannedBatch + batches: Array + } + | { type: `open`; position: number; batch: PlannedBatch } + | { type: `close`; position: number; commit: boolean } + | { type: `sync`; position: number; batch: PlannedBatch } + +export type OptimisticPlan = { + history: OptimisticHistory + actions: Array +} + +const checkpointOf = (action: PlanAction) => + action.position === `final` + ? `final release-store` + : `step ${action.position} (${action.type})` + +/** + * Replays the model over a history. Resolution keeps the actions; the model + * keeps what each checkpoint owes. Both read one replay, so a step means the + * same thing to the driver and to the judgment. + */ +function replayOptimisticHistory(history: OptimisticHistory) { + const { initial, steps } = history + const partialUpdates = history.partialUpdates === true + const model = new HistoryModel(initial, history.insertDefault, partialUpdates) + const actions: Array = [] + const owed = new Map() + const states = new Set() + // The source admits each message against its own rows, including queued + // ones: an insert names an absent key, an update a present one; it may + // delete a key it does not hold. + const sourceKeys = new Set(initial.map((row) => row.id)) + const sourceRows = new Map(initial.map((row) => [row.id, row])) + let open: + | { batch: PlannedBatch; keys: Set; rows: Map } + | undefined + // Expected outcome of each request, by index. + const outcomes: Array = [] + // Each request's authored changes, computed when it is made. + const changes: Array = [] + const plan = (batch: SourceBatch): PlannedBatch => { + if (batch.operations && batch.copies !== 1) + throw new Error(`Ordered source batches have one copy`) + if (batch.operations && (batch.rows.length || batch.deletes?.length)) + throw new Error(`Ordered source batches use operations alone`) + if (batch.truncate) { + sourceKeys.clear() + sourceRows.clear() + states.add(`truncate`) + } + const planned: PlannedBatch = { + batch, + rowKinds: new Map(), + absentDeletes: new Set(), + distinguishingPartials: 0, + } + for (const [index, operation] of sourceOperations(batch).entries()) { + if (operation.type === `delete`) { + if (!sourceKeys.delete(operation.key)) { + planned.absentDeletes.add(index) + states.add(`absent-source-delete`) + } + sourceRows.delete(operation.key) + states.add(`source-delete`) + continue + } + const { row } = operation + const kind = sourceKeys.has(row.id) ? `update` : `insert` + planned.rowKinds.set(index, kind) + if (kind === `insert`) states.add(`source-insert`) + sourceKeys.add(row.id) + const held = sourceRows.get(row.id) + if (kind === `update` && partialUpdates && batch.partial) { + if (!held) throw new Error(`Partial update of unknown source row`) + if (held.c !== row.c) { + planned.distinguishingPartials++ + states.add(`distinguishing-partial-update`) + } + sourceRows.set(row.id, { ...row, c: held.c }) + } else sourceRows.set(row.id, row) + } + return planned + } + const persisting = () => + model.transactions.some((entry) => entry.state === `persisting`) + const committed = (batch: SourceBatch) => { + if (persisting()) + states.add(batch.truncate ? `snapshot-override` : `queued`) + } + const visible = () => sorted(model.visible().values()) + let cuts = [visible()] + const owe = (checkpoint: string, extra: Partial = {}) => { + // The model keeps two accounts of each request, its owed outcome and its + // transaction state; they must agree, or the model itself is wrong. + for (const [index, entry] of model.transactions.entries()) { + const status = + entry.state === `pending` || entry.state === `persisting` + ? `pending` + : entry.state === `completed` + ? `fulfilled` + : `rejected` + if (outcomes[index]?.status !== status) + throw new Error( + `The model owes request ${index} ${outcomes[index]?.status} at ${checkpoint}, but its transaction is ${entry.state}`, + ) + } + owed.set(checkpoint, { + cuts, + visible: visible(), + outcomes: outcomes.map((outcome) => ({ ...outcome })), + requests: model.transactions.map((entry, index) => ({ + state: entry.state, + row: plain(entry.row), + changes: changes[index]!, + })), + waiting: [...model.queue, ...model.deferred], + unsettled: model.transactions.flatMap((entry, index) => + entry.state === `pending` || entry.state === `persisting` + ? [index] + : [], + ), + ...extra, + }) + } + owe(`initial`) + for (const [position, step] of steps.entries()) { + const before = visible() + if (step.type === `edit` || step.type === `delete`) { + if (step.type === `edit` && step.pending && step.inHandler) + throw new Error(`A pending edit cannot write from its handler yet`) + const index = model.author(step) + if (index === undefined) continue + const intent = model.transactions[index]! + states.add(step.type === `delete` ? `delete` : `edit`) + if (step.type === `edit` && step.pending) states.add(`manual`) + outcomes.push({ status: `pending` }) + const schemaInsert = + history.insertDefault !== undefined && + intent.kind === `insert` && + step.type === `edit` && + step.fields.c === undefined + changes.push( + step.type === `delete` + ? plain(intent.row) + : intent.kind === `insert` + ? schemaInsert + ? { id: intent.key, a: intent.row.a, b: intent.row.b } + : plain(intent.row) + : // An authored field is a change unless it is the same value; + // -0 and 0 are distinct row values (maintainer decision, + // 2026-10-29). + Object.fromEntries( + Object.entries(step.fields).filter( + ([key, value]) => + !Object.is( + before.find((row) => row.id === intent.key)?.[ + key as keyof Fields + ], + value, + ), + ), + ), + ) + cuts = [visible()] + let handler: (PlannedBatch & { awaitReceipt: boolean }) | undefined + if (step.inHandler && !open) { + const awaitReceipt = step.inHandler.awaitReceipt === true + // Acceptance waits for the wrapping sync's durable step, so a + // handler awaiting it would wait for the held store. + if (awaitReceipt && (model.storeHeld || model.holdNext)) + throw new Error(`A held store excludes awaited handler receipts`) + handler = { ...plan(step.inHandler), awaitReceipt } + model.sync(step.inHandler) + committed(step.inHandler) + cuts.push(visible()) + states.add(`handler-batch`) + if (awaitReceipt) states.add(`awaited-receipt`) + } + const action: PlanAction = { + type: `edit`, + position, + step, + index, + kind: intent.kind, + row: plain(intent.row), + manual: step.type === `edit` && step.pending === true, + handler, + } + actions.push(action) + owe(checkpointOf(action), { + live: step.optimistic + ? step.type === `delete` + ? null + : plain(intent.row) + : undefined, + captured: plain(intent.row), + }) + continue + } + if (step.type === `complete`) { + if (!model.transactions.length) continue + const index = step.slot % model.transactions.length + const admitted = model.transactions[index]!.state === `pending` + states.add(admitted ? `completion` : `completion-rejection`) + if (admitted) { + model.settle(index, true, false) + outcomes[index] = { status: `fulfilled` } + cuts = [visible()] + } + const action: PlanAction = { type: `complete`, position, index } + actions.push(action) + owe(checkpointOf(action), { + completion: admitted ? `admitted` : `rejected`, + settledRows: admitted ? cuts[0] : undefined, + }) + continue + } + if (step.type === `settle`) { + const active = model.transactions.flatMap((entry, index) => + entry.state === `persisting` || entry.state === `pending` + ? [index] + : [], + ) + if (!active.length) continue + const index = active[step.slot % active.length]! + const canceled = model.settle( + index, + step.success, + step.failure === `reject` || step.cascade, + ) + for (const peer of canceled) outcomes[peer] = { status: `rejected` } + outcomes[index] = step.success + ? { status: `fulfilled` } + : step.failure === `reject` + ? { status: `rejected`, reason: `handler error` } + : { status: `rejected` } + states.add(step.success ? `settlement` : `failure`) + // The drop and the queued sync transactions publish together. + cuts = [visible()] + const action: PlanAction = { + type: `settle`, + position, + index, + success: step.success, + failure: step.failure, + cascade: step.cascade, + } + actions.push(action) + owe(checkpointOf(action), { + settledRows: cuts[0], + // A settlement that changes no visible row publishes nothing. Strict + // equality, so a row whose zero changed sign counts as changed. + quiet: isDeepStrictEqual(before, visible()), + }) + continue + } + if (step.type === `hold-store`) { + model.holdStore() + const action: PlanAction = { type: `hold-store`, position } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `release-store`) { + cuts = [visible()] + model.releaseStore(() => cuts.push(visible())) + const action: PlanAction = { type: `release-store`, position } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `abort-deferred`) { + const aborted = model.abortDeferred(step.slot) + if (!aborted) continue + states.add(`aborted-deferred`) + const action: PlanAction = { + type: `abort-deferred`, + position, + batch: aborted, + } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `reentrant`) { + if (open) continue + if (!step.trigger.truncate) + throw new Error(`Reentrant trigger must publish a truncate`) + const trigger = plan(step.trigger) + const batches = step.batches.map(plan) + if (!batches.some(({ batch }) => batch.truncate)) + throw new Error(`Reentrant suffix must include a truncate`) + model.sync(step.trigger) + committed(step.trigger) + cuts = [visible()] + model.syncTogether(step.batches) + for (const batch of step.batches) committed(batch) + cuts.push(visible()) + states.add(`reentrant`) + const action: PlanAction = { + type: `reentrant`, + position, + trigger, + batches, + } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `open`) { + if (open || step.batch.truncate) continue + const keys = new Set(sourceKeys) + const rows = new Map(sourceRows) + open = { batch: plan(step.batch), keys, rows } + states.add(`open-batch`) + const action: PlanAction = { type: `open`, position, batch: open.batch } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (step.type === `close`) { + if (!open) continue + const { batch, keys, rows } = open + open = undefined + if (step.commit) { + model.sync(batch.batch) + committed(batch.batch) + cuts = [visible()] + } else { + // Aborted before acceptance: its writes never apply. + sourceKeys.clear() + for (const key of keys) sourceKeys.add(key) + sourceRows.clear() + for (const [key, row] of rows) sourceRows.set(key, row) + states.add(`aborted-batch`) + } + const action: PlanAction = { + type: `close`, + position, + commit: step.commit, + } + actions.push(action) + owe(checkpointOf(action)) + continue + } + if (open) continue + const batch = plan(step) + model.sync(step) + committed(step) + cuts = [visible()] + const action: PlanAction = { type: `sync`, position, batch } + actions.push(action) + owe(checkpointOf(action)) + } + // Every history ends with its deferred transactions applied. + if (model.storeHeld || model.holdNext) { + cuts = [visible()] + model.releaseStore(() => cuts.push(visible())) + const action: PlanAction = { type: `release-store`, position: `final` } + actions.push(action) + owe(checkpointOf(action)) + } + if (model.deferredHoldsAcrossDrain > 0) states.add(`deferred-hold`) + if (model.abortReleases > 0) states.add(`abort-release`) + if (model.abortAttributionReleases > 0) + states.add(`abort-attribution-release`) + // At cleanup every request still pending is rolled back, and every receipt + // settles: an aborted transaction's rejects, every other one fulfills. + const final = { + outcomes: outcomes.map((outcome) => + outcome.status === `pending` ? { status: `rejected` as const } : outcome, + ), + aborted: model.aborted, + } + return { actions, owed, states, final } +} + +type OwedOutcome = + | { status: `pending` } + | { status: `fulfilled` } + | { status: `rejected`; reason?: `handler error` } + +/** What one checkpoint owes. */ +type OwedCheckpoint = { + /** The visible rows a publication may show, in order. */ + cuts: Array> + visible: Array + outcomes: Array + requests: Array<{ + state: ModelTransaction[`state`] + row: HistoryRow + changes: object + }> + /** Committed source transactions that have not applied. */ + waiting: Array + unsettled: Array + /** The row an optimistic mutation shows in the tick that makes it. */ + live?: HistoryRow | null + /** The row the request captured when it was made. */ + captured?: HistoryRow + completion?: `admitted` | `rejected` + settledRows?: Array + quiet?: boolean +} + +export function resolveOptimisticHistory( + history: OptimisticHistory, +): OptimisticPlan { + return { history, actions: replayOptimisticHistory(history).actions } +} + +export function optimisticHistoryModel(plan: OptimisticPlan) { + const { owed, states, final } = replayOptimisticHistory(plan.history) + return { owed, states, final } +} + +/** The states a history entered, which the model names. */ +export function optimisticHistoryReach( + _plan: OptimisticPlan, + _trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +): Iterable { + return expected.states +} + +export const optimisticHistoryStates = [ + `edit`, + `delete`, + `manual`, + `settlement`, + `failure`, + `completion`, + `completion-rejection`, + `queued`, + `snapshot-override`, + `truncate`, + `handler-batch`, + `awaited-receipt`, + `open-batch`, + `aborted-batch`, + `source-insert`, + `source-delete`, + `absent-source-delete`, + `distinguishing-partial-update`, + `reentrant`, + `aborted-deferred`, + `deferred-hold`, + `abort-release`, + `abort-attribution-release`, +] as const + +/** Each source transaction in the plan, numbered in plan order. */ +function batchIds(plan: OptimisticPlan): Map { + const ids = new Map() + const add = (batch: SourceBatch) => { + if (!ids.has(batch)) ids.set(batch, ids.size) + } + for (const action of plan.actions) { + if (action.type === `edit` && action.handler) add(action.handler.batch) + if (action.type === `reentrant`) { + add(action.trigger.batch) + for (const { batch } of action.batches) add(batch) + } + if (action.type === `open` || action.type === `sync`) + add(action.batch.batch) + } + return ids +} + +type Change = { + key: unknown + type: string + value: ObservedRow + previousValue?: ObservedRow +} +/** A publication to the subscriber that started with the initial rows. */ +type RecordedPublication = { + before: Array<[unknown, ObservedRow]> + batch: Array + /** The subscriber's replica after the batch. */ + replica: Array + /** The Collection's rows when the callback ran. */ + source: Array +} +/** A publication to the subscriber that started from the visible rows. */ +type RawPublication = { + before: Array<[unknown, ObservedRow]> + batch: Array +} +type RecordedOutcome = { + status: `pending` | `fulfilled` | `rejected` + /** Whether a fulfilled request resolved to its own transaction. */ + ownTransaction?: boolean + /** Whether a rejected request carries the error its handler threw. */ + handlerError?: boolean +} +/** What production showed at one checkpoint. */ +export type OptimisticObservation = { + publications: Array + raw: Array + requests: Array<{ + outcome: RecordedOutcome + state: string + modified: HistoryRow + changes: object + }> + /** Receipt status of each source transaction that returned one, by id. */ + receipts: Array<[number, `pending` | `fulfilled` | `rejected`]> + rows: Array + replica: Array + rawReplica: Array + downstream: Array + /** The Collection's tracked transactions, as request indexes (-1: unknown). */ + tracked: Array + deliveries: number + /** The row right after the mutation call, before any await. */ + live?: HistoryRow | null + captured?: HistoryRow + /** Whether the handler wrote its source transaction before it returned. */ + handlerWrote?: boolean + completion?: `admitted` | `rejected` + settledRows?: Array + /** Whether a reentrant subscriber committed the suffix during the trigger. */ + suffixCommitted?: boolean + /** An aborted open transaction: its receipt and its acceptance reject. */ + abortedOpen?: { receiptRejected: boolean; acceptanceRejected: boolean } +} +export type OptimisticCleanup = { + outcomes: Array + receipts: Array<[number, `pending` | `fulfilled` | `rejected`]> +} + +/** + * The production driver for one production path: core, or a wrapping sync such as + * persistence. `fullRows` sets up the partial-update production path in `full` row update + * mode, a mutant configuration that a calibration uses. + */ +export function optimisticHistoryDriver( + productionPath: { wrapping?: WrappingSyncPath; fullRows?: boolean } = {}, +) { + return async ( + plan: OptimisticPlan, + observe: (checkpoint: string, value: unknown) => void, + ): Promise => { + const { history } = plan + const { wrapping } = productionPath + const partialUpdates = history.partialUpdates === true + const ids = batchIds(plan) + const db: HistoryDb = wrapping?.db ?? { + createCollection, + createLiveQueryCollection, + createTransaction, + whenSyncAccepted, + } + if (wrapping && history.insertDefault !== undefined) + throw new Error(`The wrapping-sync production path has no schema grammar`) + if ( + !wrapping && + plan.actions.some( + (action) => + action.type === `hold-store` || + action.type === `release-store` || + action.type === `abort-deferred`, + ) + ) + throw new Error(`Store steps need a wrapping sync`) + if (wrapping && plan.actions.some((action) => action.type === `reentrant`)) + throw new Error( + `The wrapping-sync production path has no reentrant drain`, + ) + let sync!: Parameters[`sync`]>[0] + let starting: ReturnType> | undefined + // The next handler call writes this batch before it returns. + let handlerBatch: (PlannedBatch & { awaitReceipt: boolean }) | undefined + // The suffix a subscriber commits during the next publication. An object, + // so the driver reads what the callback clears. + const reentrant: { batches?: Array } = {} + const pendingSuffix = () => reentrant.batches + const handler = async () => { + const batch = handlerBatch + const done = starting!.promise + handlerBatch = undefined + if (batch) { + const receipt = writeSourceBatch(batch) + // Handler-facing writes wait for acceptance. Waiting for visibility + // would wait for this handler's own transaction to settle. + if (batch.awaitReceipt) await db.whenSyncAccepted(receipt) + } + return done + } + const config: CollectionConfig = { + getKey: (row) => row.id, + onInsert: handler, + onUpdate: handler, + onDelete: handler, + sync: { + // The partial-update production path keeps the default row update mode. + ...(partialUpdates && !productionPath.fullRows + ? {} + : { rowUpdateMode: `full` as const }), + sync: (actions) => { + sync = actions + actions.begin() + for (const row of history.initial) + actions.write({ type: `insert`, value: { ...row } }) + actions.commit() + actions.markReady() + }, + }, + } + const schemaCollection = + history.insertDefault === undefined + ? undefined + : db.createCollection({ + ...config, + schema: z.object({ + id: z.number(), + a: z.number(), + b: z.number(), + c: z.number().default(history.insertDefault), + }), + }) + const collection = + schemaCollection ?? + db.createCollection(wrapping ? wrapping.wrap(config) : config) + const downstream = db.createLiveQueryCollection({ + query: (q) => q.from({ row: collection }), + }) + let sub: ReturnType | undefined + let rawSub: ReturnType | undefined + const requests: Array<{ + tx: + | ReturnType + | ReturnType + | ReturnType> + done: ReturnType> + outcome: ReturnType> + settledRows?: Array + handlerError?: Error + manual: boolean + }> = [] + const receipts: Array<{ + id: number + outcome: ReturnType> + }> = [] + const sourceRows = new Map(history.initial.map((row) => [row.id, row])) + // An open source transaction and the source's rows before it began. + let opened: + { batch: PlannedBatch; rows: Map } | undefined + function beginSourceBatch({ batch, rowKinds }: PlannedBatch) { + sync.begin() + if (batch.truncate) { + sync.truncate() + sourceRows.clear() + } + for (const [index, operation] of sourceOperations(batch).entries()) { + if (operation.type === `delete`) { + sync.write({ type: `delete`, key: operation.key }) + sourceRows.delete(operation.key) + continue + } + const { row } = operation + const type = rowKinds.get(index)! + const held = sourceRows.get(row.id) + if (type === `update` && partialUpdates && batch.partial && held) { + const { c: _omitted, ...partialRow } = row + sync.write({ type, value: partialRow as HistoryRow }) + sourceRows.set(row.id, { ...row, c: held.c }) + } else { + sync.write({ type, value: { ...row } }) + sourceRows.set(row.id, row) + } + } + } + // In a wrapping-sync production path each source transaction carries its own signal, + // so a history can abort it while it is deferred. + const controllers = new Map() + function commitSourceBatch({ batch }: PlannedBatch) { + let signal: AbortSignal | undefined + if (wrapping) { + const controller = new AbortController() + controllers.set(batch, controller) + signal = controller.signal + } + const receipt = sync.commit(signal) + if (receipt !== true) + receipts.push({ + id: ids.get(batch)!, + outcome: observeHistoryPromise(receipt), + }) + return receipt + } + function writeSourceBatch(batch: PlannedBatch) { + beginSourceBatch(batch) + return commitSourceBatch(batch) + } + const outcomeOf = (request: (typeof requests)[number]): RecordedOutcome => { + const current = request.outcome.read() + if (current.status === `fulfilled`) + return { + status: `fulfilled`, + ownTransaction: current.value === request.tx, + } + if (current.status === `rejected`) + return { + status: `rejected`, + handlerError: + request.handlerError !== undefined && + current.reason === request.handlerError, + } + return { status: `pending` } + } + return withHistoryCleanup( + async () => { + await downstream.preload() + await wrapping?.settle() + const replica = new Map() + let deliveries = 0 + let publications: Array = [] + const capture = ( + batch: ReadonlyArray<{ + key: unknown + type: string + value: HistoryRow + previousValue?: HistoryRow + }>, + ): Array => + batch.map((change) => ({ + key: change.key, + type: change.type, + value: observed(change.value as ObservedRow), + ...(`previousValue` in change + ? { + previousValue: + change.previousValue === undefined + ? undefined + : observed(change.previousValue as ObservedRow), + } + : {}), + })) + sub = collection.subscribeChanges( + (batch) => { + deliveries += batch.length + const captured = capture(batch) + const before = [...replica] + for (const change of captured) { + if (change.type === `delete`) replica.delete(change.key as number) + else replica.set(change.key as number, change.value) + } + publications.push({ + before, + batch: captured, + replica: sorted(replica.values()), + source: sorted([...collection.values()].map(observed)), + }) + if (reentrant.batches) { + const batches = reentrant.batches + reentrant.batches = undefined + for (const suffix of batches) writeSourceBatch(suffix) + } + }, + { includeInitialState: true }, + ) + // A raw subscriber starts from the visible rows and receives only later + // changes. It has no sent-key filter to hide an invalid message. + const rawReplica = new Map( + [...collection.values()].map((row) => [ + row.id, + observed(row as ObservedRow), + ]), + ) + let raw: Array = [] + rawSub = collection.subscribeChanges( + (batch) => { + const before = [...rawReplica] + const captured = capture(batch) + for (const change of captured) { + if (change.type === `delete`) rawReplica.delete(change.key) + else rawReplica.set(change.key, change.value) + } + raw.push({ before, batch: captured }) + }, + { includeInitialState: false }, + ) + const record = ( + checkpoint: string, + extra: Partial = {}, + ) => { + const observation: OptimisticObservation = { + publications, + raw, + requests: requests.map((request) => ({ + outcome: outcomeOf(request), + state: request.tx.state, + modified: plain(request.tx.mutations[0]!.modified as HistoryRow), + changes: request.tx.mutations[0]!.changes, + })), + receipts: receipts.map(({ id, outcome }) => [ + id, + outcome.read().status, + ]), + rows: sorted([...collection.values()].map(observed)), + replica: sorted(replica.values()), + rawReplica: sorted(rawReplica.values()), + downstream: sorted([...downstream.values()].map(plain)), + tracked: [...collection._state.transactions.values()].map((tx) => + requests.findIndex((request) => request.tx === tx), + ), + deliveries, + ...extra, + } + publications = [] + raw = [] + observe(checkpoint, observation) + } + record(`initial`) + for (const action of plan.actions) { + const checkpoint = checkpointOf(action) + if (action.type === `edit`) { + const { step } = action + handlerBatch = action.handler + const done = createDeferred() + // Rejection can precede a manual commit. Keep that controlled + // failure from becoming an unhandled promise during diagnosis. + void done.promise.catch(() => undefined) + starting = done + const performEdit = () => + step.type === `delete` + ? collection.delete(step.key, { optimistic: step.optimistic }) + : action.kind === `insert` + ? schemaCollection && step.fields.c === undefined + ? schemaCollection.insert( + { id: action.row.id, a: action.row.a, b: action.row.b }, + { optimistic: step.optimistic }, + ) + : collection.insert( + { ...action.row }, + { optimistic: step.optimistic }, + ) + : collection.update( + step.key, + { optimistic: step.optimistic }, + (draft) => Object.assign(draft, step.fields), + ) + const tx = action.manual + ? db.createTransaction({ + autoCommit: false, + // A manual commit can run after later edits replace `starting`. + mutationFn: () => done.promise, + }) + : performEdit() + if (action.manual) tx.mutate(() => void performEdit()) + // An optimistic mutation is visible in the tick that makes it. + const current = collection.get(step.key) + const live = step.optimistic + ? current + ? plain(current) + : null + : undefined + const request: (typeof requests)[number] = { + tx, + done, + manual: action.manual, + outcome: observeHistoryPromise( + tx.when(`settled`).finally(() => { + request.settledRows = sorted( + [...collection.values()].map(observed), + ) + }), + ), + } + requests.push(request) + const captured = plain(tx.mutations[0]!.modified as HistoryRow) + const handlerWrote = handlerBatch === undefined + await wrapping?.settle() + record(checkpoint, { live, captured, handlerWrote }) + continue + } + if (action.type === `complete`) { + const request = requests[action.index]! + let completion: `admitted` | `rejected` = `admitted` + try { + request.tx.completeWithoutCommit() + } catch { + completion = `rejected` + } + if (completion === `admitted`) { + request.tx.touchCollection() + await withinTurns(request.outcome.settled, 10) + } + await wrapping?.settle() + record(checkpoint, { completion, settledRows: request.settledRows }) + continue + } + if (action.type === `settle`) { + const request = requests[action.index]! + if (action.success) { + if (request.manual) + void request.tx.commit().catch(() => undefined) + request.done.resolve() + } else if (action.failure === `reject`) { + request.handlerError = new Error( + `Mutation rejected at step ${action.position}`, + ) + if (request.manual) + void request.tx.commit().catch(() => undefined) + request.done.reject(request.handlerError) + } else { + request.tx.rollback({ isSecondaryRollback: !action.cascade }) + request.done.resolve() + } + // A receipt that waited for visibility would deadlock the handler, + // so the wait is bounded in event-loop turns, not milliseconds. + await withinTurns(request.outcome.settled, 10) + await Promise.resolve() + const settledRows = request.settledRows + await wrapping?.settle() + record(checkpoint, { settledRows }) + continue + } + if (action.type === `hold-store`) wrapping!.holdNextStore() + else if (action.type === `release-store`) wrapping!.releaseStore() + else if (action.type === `abort-deferred`) + controllers.get(action.batch)!.abort() + else if (action.type === `reentrant`) { + reentrant.batches = action.batches + writeSourceBatch(action.trigger) + const suffixCommitted = pendingSuffix() === undefined + await wrapping?.settle() + record(checkpoint, { suffixCommitted }) + continue + } else if (action.type === `open`) { + opened = { batch: action.batch, rows: new Map(sourceRows) } + beginSourceBatch(action.batch) + } else if (action.type === `close`) { + const { batch, rows } = opened! + opened = undefined + if (action.commit) commitSourceBatch(batch) + else { + // Aborted before acceptance: the source's rows return to their + // state before the transaction began. + sourceRows.clear() + for (const [key, row] of rows) sourceRows.set(key, row) + const controller = new AbortController() + controller.abort() + const receipt = sync.commit(controller.signal) + const isAbort = (error: unknown) => + (error as { name?: unknown } | null)?.name === `AbortError` + const receiptRejected = + receipt instanceof Promise && + (await receipt.then(() => false, isAbort)) + const acceptanceRejected = await Promise.resolve( + db.whenSyncAccepted(receipt), + ).then(() => false, isAbort) + await wrapping?.settle() + record(checkpoint, { + abortedOpen: { receiptRejected, acceptanceRejected }, + }) + continue + } + } else writeSourceBatch(action.batch) + await wrapping?.settle() + record(checkpoint) + } + }, + () => [ + () => wrapping?.releaseStore(), + // A request still pending at the end is rolled back, one at a time. + ...requests.flatMap((request) => [ + () => { + if ( + request.tx.state === `pending` || + request.tx.state === `persisting` + ) + request.tx.rollback({ isSecondaryRollback: true }) + }, + () => request.done.resolve(), + // Bounded, so a request that never settles fails the cleanup law + // instead of hanging the run. + () => withinTurns(request.outcome.settled, 10), + ]), + () => sub?.unsubscribe(), + () => rawSub?.unsubscribe(), + () => downstream.cleanup(), + () => collection.cleanup(), + async () => { + for (const { outcome } of receipts) + await withinTurns(outcome.settled, 10) + const cleanup: OptimisticCleanup = { + outcomes: requests.map(outcomeOf), + receipts: receipts.map(({ id, outcome }) => { + return [id, outcome.read().status] + }), + } + observe(`cleanup`, cleanup) + }, + ], + ) + } +} + +const same = isDeepStrictEqual + +function outcomeMatches(seen: RecordedOutcome, owed: OwedOutcome): boolean { + if (seen.status !== owed.status) return false + if (owed.status === `fulfilled`) return seen.ownTransaction === true + if (owed.status === `rejected` && owed.reason === `handler error`) + return seen.handlerError === true + return true +} + +/** + * Compares each checkpoint with what the model owes. The laws, in the order + * a checkpoint is judged: + * - `same-tick`: an optimistic mutation is visible in the tick that makes it. + * - `request-snapshot`: a request captures the row it authors. + * - `handler-batch`: a handler writes its source transaction before it returns. + * - `external-completion`: external completion admits only a pending request. + * - `settled-rows`: `when('settled')` settles after the publication that + * drops the request's optimistic state. + * - `reentrant-suffix` and `aborted-open-batch`: the reentrant subscriber + * commits its suffix during the trigger, and an aborted open transaction's + * receipt and acceptance both reject. + * - `callback-shape`: a callback key is the row's numeric id. + * - `event-semantics`: every batch is valid for its subscriber's replica. + * - `forward-cuts`: each publication shows an allowed state at or after the + * previous one, and the Collection shows the same state when it runs. + * - `outcomes`, `transaction-state`, `authored-changes`: each request's + * outcome, state, immutable row, and authored changes. + * - `receipts`: a committed source transaction's receipt waits until it + * applies. + * - `rows`: the Collection, both replicas, and the downstream query show the + * visible rows. + * - `tracked-transactions`: the Collection tracks exactly its unsettled + * transactions, by identity. + * - `quiet-settlement`: a settlement that changes no visible row publishes + * nothing. + * - `cleanup`: at the end every request and receipt settles as owed. + * The trace must cover every checkpoint (`trace`); that is judged last. + */ +export function checkOptimisticHistory( + plan: OptimisticPlan, + trace: ReadonlyArray<{ checkpoint: string; value: unknown }>, + expected: ReturnType, +) { + const ids = batchIds(plan) + let deliveries = 0 + for (const { checkpoint, value } of trace) { + if (checkpoint === `cleanup`) { + const cleanup = value as OptimisticCleanup + for (const [index, outcome] of cleanup.outcomes.entries()) { + const owed = expected.final.outcomes[index] + if (!owed || !outcomeMatches(outcome, owed)) + return violation( + checkpoint, + `request ${index} ended ${JSON.stringify(outcome)}, owed ${JSON.stringify(owed)}`, + `cleanup`, + { actual: outcome, expected: owed }, + ) + } + const aborted = new Set( + [...expected.final.aborted].map((batch) => ids.get(batch)), + ) + for (const [id, status] of cleanup.receipts) { + const owed = aborted.has(id) ? `rejected` : `fulfilled` + if (status !== owed) + return violation( + checkpoint, + `receipt of source transaction ${id} ${status}, owed ${owed}`, + `cleanup`, + { actual: status, expected: owed }, + ) + } + continue + } + const owed = expected.owed.get(checkpoint) + if (!owed) + return violation( + checkpoint, + `the driver recorded a checkpoint the plan does not have`, + `trace`, + ) + const seen = value as OptimisticObservation + if (owed.live !== undefined && !same(seen.live, owed.live)) + return violation( + checkpoint, + `the row right after the call is ${JSON.stringify(seen.live)}, owed ${JSON.stringify(owed.live)}`, + `same-tick`, + { actual: seen.live, expected: owed.live }, + ) + if (owed.captured && !same(seen.captured, owed.captured)) + return violation( + checkpoint, + `the request captured ${JSON.stringify(seen.captured)}, owed ${JSON.stringify(owed.captured)}`, + `request-snapshot`, + { actual: seen.captured, expected: owed.captured }, + ) + if (seen.handlerWrote === false) + return violation( + checkpoint, + `the handler returned before writing its source transaction`, + `handler-batch`, + { actual: seen.handlerWrote, expected: true }, + ) + if (owed.completion && seen.completion !== owed.completion) + return violation( + checkpoint, + `external completion was ${seen.completion}, owed ${owed.completion}`, + `external-completion`, + { actual: seen.completion, expected: owed.completion }, + ) + if (owed.settledRows && !same(seen.settledRows, owed.settledRows)) + return violation( + checkpoint, + `rows when settlement completed ${JSON.stringify(seen.settledRows)}, owed ${JSON.stringify(owed.settledRows)}`, + `settled-rows`, + { actual: seen.settledRows, expected: owed.settledRows }, + ) + if (seen.suffixCommitted === false) + return violation( + checkpoint, + `the subscriber did not commit the suffix during the trigger`, + `reentrant-suffix`, + { actual: seen.suffixCommitted, expected: true }, + ) + if ( + seen.abortedOpen && + !(seen.abortedOpen.receiptRejected && seen.abortedOpen.acceptanceRejected) + ) + return violation( + checkpoint, + `an aborted open transaction: ${JSON.stringify(seen.abortedOpen)}`, + `aborted-open-batch`, + { + actual: seen.abortedOpen, + expected: { receiptRejected: true, acceptanceRejected: true }, + }, + ) + for (const publication of seen.raw) { + const difference = eventSemanticsDifference( + new Map(publication.before), + publication.batch, + ) + if (difference) + return violation( + checkpoint, + `raw subscriber: ${difference}`, + `event-semantics`, + ) + } + let cut = 0 + for (const publication of seen.publications) { + // Shape first: a key of the wrong type would otherwise read as absent. + for (const change of publication.batch) + if (typeof change.key !== `number` || change.key !== change.value.id) + return violation( + checkpoint, + `callback key ${JSON.stringify(change.key)} for row ${change.value.id}`, + `callback-shape`, + { actual: change.key, expected: change.value.id }, + ) + const difference = eventSemanticsDifference( + new Map(publication.before), + publication.batch, + ) + if (difference) + return violation(checkpoint, difference, `event-semantics`) + const next = forwardCut(owed.cuts, publication.replica, cut, same) + if (next < 0) + return violation( + checkpoint, + `publication shows ${JSON.stringify(publication.replica)}; allowed from cut ${cut}: ${JSON.stringify(owed.cuts)}`, + `forward-cuts`, + { actual: publication.replica, expected: owed.cuts.slice(cut) }, + ) + if (!same(publication.source, owed.cuts[next])) + return violation( + checkpoint, + `the Collection showed ${JSON.stringify(publication.source)} while publishing ${JSON.stringify(owed.cuts[next])}`, + `forward-cuts`, + { actual: publication.source, expected: owed.cuts[next] }, + ) + cut = next + } + if (seen.requests.length !== owed.requests.length) + return violation( + checkpoint, + `${seen.requests.length} requests recorded, ${owed.requests.length} owed`, + `trace`, + ) + for (const [index, request] of seen.requests.entries()) { + const owedRequest = owed.requests[index]! + if (!outcomeMatches(request.outcome, owed.outcomes[index]!)) + return violation( + checkpoint, + `request ${index} is ${JSON.stringify(request.outcome)}, owed ${JSON.stringify(owed.outcomes[index])}`, + `outcomes`, + { actual: request.outcome, expected: owed.outcomes[index] }, + ) + if (request.state !== owedRequest.state) + return violation( + checkpoint, + `request ${index} state ${request.state}, owed ${owedRequest.state}`, + `transaction-state`, + { actual: request.state, expected: owedRequest.state }, + ) + if (!same(request.modified, owedRequest.row)) + return violation( + checkpoint, + `request ${index} holds ${JSON.stringify(request.modified)}, owed ${JSON.stringify(owedRequest.row)}`, + `transaction-state`, + { actual: request.modified, expected: owedRequest.row }, + ) + if (!same(request.changes, owedRequest.changes)) + return violation( + checkpoint, + `request ${index} authored ${JSON.stringify(request.changes)}, owed ${JSON.stringify(owedRequest.changes)}`, + `authored-changes`, + { actual: request.changes, expected: owedRequest.changes }, + ) + } + const waiting = new Set(owed.waiting.map((batch) => ids.get(batch))) + for (const [id, status] of seen.receipts) + if (waiting.has(id) && status !== `pending`) + return violation( + checkpoint, + `receipt of source transaction ${id} ${status} before it applied`, + `receipts`, + { actual: status, expected: `pending` }, + ) + for (const [name, rows] of [ + [`Collection`, seen.rows], + [`event replica`, seen.replica], + [`raw event replica`, seen.rawReplica], + ] as const) + if (!same(rows, owed.visible)) + return violation( + checkpoint, + `${name} shows ${JSON.stringify(rows)}, owed ${JSON.stringify(owed.visible)}`, + `rows`, + { actual: rows, expected: owed.visible }, + ) + if (!same(seen.downstream, owed.visible.map(plain))) + return violation( + checkpoint, + `downstream shows ${JSON.stringify(seen.downstream)}`, + `rows`, + { actual: seen.downstream, expected: owed.visible.map(plain) }, + ) + const tracked = [...seen.tracked].sort((a, b) => a - b) + if (!same(tracked, owed.unsettled)) + return violation( + checkpoint, + `tracked transactions ${JSON.stringify(tracked)}, unsettled ${JSON.stringify(owed.unsettled)}`, + `tracked-transactions`, + { actual: tracked, expected: owed.unsettled }, + ) + if (owed.quiet && seen.deliveries !== deliveries) + return violation( + checkpoint, + `${seen.deliveries - deliveries} deliveries for a settlement that changed no visible row`, + `quiet-settlement`, + { actual: seen.deliveries, expected: deliveries }, + ) + deliveries = seen.deliveries + } + const recorded = new Set(trace.map(({ checkpoint }) => checkpoint)) + for (const checkpoint of [...expected.owed.keys(), `cleanup`]) + if (!recorded.has(checkpoint)) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}" checkpoint`, + `trace`, + ) + return pass +} + +// -- Calibrations -------------------------------------------------------------- +// +// The observation mutants, as corruptions of a recorded trace. Each alters a +// copy of a real publication or reading at the first checkpoint where it +// applies, and names the law that must reject it. + +type Entry = { checkpoint: string; value: unknown } + +/** Replaces the observation at the first checkpoint `change` alters. */ +function corruptFirst( + trace: ReadonlyArray, + change: ( + seen: OptimisticObservation, + checkpoint: string, + ) => OptimisticObservation | undefined, +): Array | undefined { + for (const [index, entry] of trace.entries()) { + if (entry.checkpoint === `cleanup`) continue + const changed = change( + entry.value as OptimisticObservation, + entry.checkpoint, + ) + if (changed) + return trace.map((other, at) => + at === index ? { ...other, value: changed } : other, + ) + } + return undefined +} + +function appliedTo( + before: Array<[unknown, ObservedRow]>, + batch: Array, +): Array<[unknown, ObservedRow]> { + const rows = new Map(before) + for (const change of batch) + if (change.type === `delete`) rows.delete(change.key) + else rows.set(change.key, change.value) + return [...rows] +} + +/** Inserts a corrupted copy before the first later publication `pick` accepts. */ +function injectBefore( + pick: (publication: RecordedPublication) => Array | undefined, +) { + return (trace: ReadonlyArray) => + corruptFirst(trace, (seen, checkpoint) => { + if (checkpoint === `initial`) return undefined + const at = seen.publications.findIndex((publication) => pick(publication)) + if (at < 0) return undefined + const real = seen.publications[at]! + const batch = pick(real)! + const fake: RecordedPublication = { + before: real.before, + batch, + replica: sorted(appliedTo(real.before, batch).map(([, row]) => row)), + source: real.source, + } + const publications = [...seen.publications] + publications.splice(at, 1, fake, { + ...real, + before: appliedTo(real.before, batch), + }) + return { ...seen, publications } + }) +} + +/** Replaces the first later publication `change` alters. */ +function alterPublication( + change: (publication: RecordedPublication) => RecordedPublication | undefined, +) { + return (trace: ReadonlyArray) => + corruptFirst(trace, (seen, checkpoint) => { + if (checkpoint === `initial`) return undefined + for (const [at, publication] of seen.publications.entries()) { + const changed = change(publication) + if (changed) { + const publications = [...seen.publications] + publications[at] = changed + return { ...seen, publications } + } + } + return undefined + }) +} + +export const optimisticHistoryCalibrations = [ + { + name: `wrong key`, + law: `callback-shape`, + corrupt: alterPublication((publication) => + publication.batch.length + ? { + ...publication, + batch: publication.batch.map((change) => ({ + ...change, + key: String(change.key), + })), + } + : undefined, + ), + }, + { + name: `transient field`, + law: `forward-cuts`, + corrupt: injectBefore((publication) => + publication.batch.length + ? publication.batch.map((change) => ({ + ...change, + value: { ...change.value, c: change.value.c + 100 }, + })) + : undefined, + ), + }, + { + name: `partial batch`, + law: `forward-cuts`, + corrupt: injectBefore((publication) => + publication.batch.length > 1 ? publication.batch.slice(0, 1) : undefined, + ), + }, + { + name: `stale previous value`, + law: `event-semantics`, + corrupt: alterPublication((publication) => + publication.batch.some((change) => change.type === `update`) + ? { + ...publication, + batch: publication.batch.map((change) => + change.type === `update` + ? { + ...change, + previousValue: { ...change.previousValue!, c: 999999 }, + } + : change, + ), + } + : undefined, + ), + }, + { + name: `update as insert`, + law: `event-semantics`, + corrupt: alterPublication((publication) => + publication.batch.some((change) => change.type === `update`) + ? { + ...publication, + batch: publication.batch.map((change) => + change.type === `update` ? { ...change, type: `insert` } : change, + ), + } + : undefined, + ), + }, + { + // Replay the initial publication after a later settlement's cut. A + // healthy runtime may coalesce an intermediate cut, but never go back. + name: `backwards cuts`, + law: `forward-cuts`, + corrupt: (trace: ReadonlyArray) => { + const initial = ( + trace.find(({ checkpoint }) => checkpoint === `initial`)?.value as + OptimisticObservation | undefined + )?.publications.at(-1) + if (!initial) return undefined + return corruptFirst(trace, (seen, checkpoint) => + checkpoint.endsWith(`(settle)`) && + seen.publications.length > 0 && + !same(initial.replica, seen.publications.at(-1)!.replica) + ? { ...seen, publications: [...seen.publications, initial] } + : undefined, + ) + }, + }, + { + name: `retained default`, + law: `rows`, + corrupt: (trace: ReadonlyArray) => + corruptFirst(trace, (seen, checkpoint) => + checkpoint.endsWith(`(settle)`) && seen.rows.length + ? { + ...seen, + rows: [{ ...seen.rows[0]!, c: -999999 }, ...seen.rows.slice(1)], + } + : undefined, + ), + }, +] diff --git a/packages/db/tests/optimistic-history-outcomes-oracle.test.ts b/packages/db/tests/optimistic-history-outcomes-oracle.test.ts index ed713a6b51..d5dc006637 100644 --- a/packages/db/tests/optimistic-history-outcomes-oracle.test.ts +++ b/packages/db/tests/optimistic-history-outcomes-oracle.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { createDeferred } from '../src/deferred.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import { @@ -31,7 +37,13 @@ import type { // The outcome checker is imported from the companion, which does not declare // it. This file's calibrations reach it through the same check. -declareOracle({ check: [expectHistoryOutcome] }) +// What this oracle owns: the gap report judges the request-outcome paths of +// transactions (src/transactions.ts) by this file's outcome matrix, its +// generated failed-request histories, and its completion histories. +declareOracle({ + owns: [`src/transactions.ts`], + check: [expectHistoryOutcome], +}) type Scenario = { existing: boolean @@ -86,6 +98,7 @@ describe(`Optimistic request outcome histories`, () => { ), ) it.each(cells)(`observes exact request outcomes: %j`, async (cell) => { + oracleRoute(`case`) for (const queued of [false, true]) { for (const settlePeer of [false, true]) { await runOutcomeScenario({ ...cell, queued, settlePeer, a: 2, b: 3 }) @@ -95,6 +108,11 @@ describe(`Optimistic request outcome histories`, () => { it.each([851203, undefined])( `varies failed request and surviving peer histories, seed=%s`, async (seed) => { + const parameters = + seed === undefined + ? oraclePropertyOptions(100, `collection-state.optimistic-outcomes`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -108,9 +126,7 @@ describe(`Optimistic request outcome histories`, () => { }), runOutcomeScenario, ), - seed === undefined - ? oraclePropertyOptions(100, `collection-state.optimistic-outcomes`) - : { seed, numRuns: oracleRuns(100) }, + parameters, ) }, ) @@ -129,6 +145,7 @@ const pendingPeerCases = [true, false].flatMap((sameKey) => it.each(pendingPeerCases)( `settles a pending manual peer after failure, sameKey=$sameKey failure=$failure cascade=$cascade`, async ({ sameKey, failure, cascade }) => { + oracleRoute(`case`) const peerSurvives = !sameKey || (failure === `rollback` && !cascade) const counts = await runOptimisticHistory( [], @@ -177,6 +194,7 @@ describe(`Outcome observer calibration`, () => { ] as const)( `rejects %s through the actual outcome checker`, async (fault) => { + oracleRoute(`calibration`) const value = { id: 1 } const error = new Error(`original`) let expected: HistoryOutcome @@ -221,6 +239,7 @@ describe(`Outcome observer calibration`, () => { }, ) it(`shrinks and replays a premature success counterexample`, () => { + oracleRoute(`calibration`) const property = fc.property(fc.integer({ min: 1, max: 100 }), (value) => { expectHistoryOutcome( { status: `pending` }, @@ -317,6 +336,7 @@ describe(`external completion with peers and source work`, () => { it.each(completionHistories)( `preserves completion admission and independent work: %j`, async ({ state, peer, source, attempts }) => { + oracleRoute(`case`) const reach = createOracleReach( `external completion ${state}/${peer}/${source}/${attempts}`, [`attempts-compared`, `later-source-compared`], diff --git a/packages/db/tests/optimistic-history-publication-oracle.test.ts b/packages/db/tests/optimistic-history-publication-oracle.test.ts index 96e46ea3e8..5c28237db0 100644 --- a/packages/db/tests/optimistic-history-publication-oracle.test.ts +++ b/packages/db/tests/optimistic-history-publication-oracle.test.ts @@ -1,5 +1,7 @@ import { expect, it } from 'vitest' import { runOptimisticHistory } from './optimistic-history-oracle.js' +import { oracleRoute } from './oracle-config.js' +import { declareOracle } from './oracle-declaration.js' import type { HistoryRow, OptimisticStep } from './optimistic-history-oracle.js' /** @@ -13,6 +15,18 @@ import type { HistoryRow, OptimisticStep } from './optimistic-history-oracle.js' * cuts, stale previous values, and updates mislabeled as inserts. */ +// What this oracle owns: the gap report judges how optimistic publication +// reaches the Collection's state, its source sync, and its transactions, by +// this file's tests. The history model and driver live in +// optimistic-history-oracle.ts, which declares its own parts. +declareOracle({ + owns: [ + `src/collection/state.ts`, + `src/collection/sync.ts`, + `src/transactions.ts`, + ], +}) + const initial: Array = [ { id: 1, a: 0, b: 0, c: 0 }, { id: 2, a: 0, b: 0, c: 0 }, @@ -21,6 +35,7 @@ const initial: Array = [ it.each([false, true])( `deletes the prior published row, queued insert=%s`, async (insert) => { + oracleRoute(`case`) await runOptimisticHistory(insert ? [] : [{ id: 2, a: 0, b: 0, c: 0 }], [ { type: `edit`, key: 1, fields: { b: 0 }, optimistic: false }, { @@ -48,6 +63,7 @@ const history = (success: boolean): Array => [ it.each([false, true])( `preserves old origin through truncate, replacement=%s`, async (replace) => { + oracleRoute(`case`) await runOptimisticHistory(initial, [ { type: `edit`, key: 1, fields: { c: 1 }, optimistic: false }, { @@ -63,6 +79,7 @@ it.each([false, true])( it.each([true, false])( `checks every complete publication through settlement %s`, async (success) => { + oracleRoute(`case`) await runOptimisticHistory(initial, history(success)) }, ) @@ -75,6 +92,7 @@ it.each([true, false])( it.each([1, 2] as const)( `attributes only the first of %i queued same-key source transactions locally`, async (sourceTransactions) => { + oracleRoute(`case`) const steps: Array = [ { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, { @@ -108,6 +126,7 @@ it.each([1, 2] as const)( // Attribution belongs to one atomic source transaction, even when it writes // the same key twice. Its final row keeps the attribution of its first write. it(`keeps local attribution through repeated same-key writes in one source transaction`, async () => { + oracleRoute(`fixed history`) const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -138,6 +157,7 @@ it(`keeps local attribution through repeated same-key writes in one source trans it.each([false, true])( `attributes a delete and reinsert in one source transaction, later=%s`, async (later) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -176,6 +196,7 @@ it.each([false, true])( // A source delete also touches the key even when that source held no row. // It consumes the one local attribution, so a later source insert is remote. it(`consumes local attribution with an absent-key source delete`, async () => { + oracleRoute(`fixed history`) const counts = await runOptimisticHistory( [], [ @@ -204,6 +225,7 @@ it(`consumes local attribution with an absent-key source delete`, async () => { // still persists. A same-key source row can therefore receive local attribution // even if that mutation later fails. The model compares both publication cuts. it(`retains active-mutation attribution from a truncate after failure`, async () => { + oracleRoute(`fixed history`) const counts = await runOptimisticHistory( [], [ @@ -240,6 +262,7 @@ it.each( )( `keeps source attribution through pending settlement, optimistic=$optimistic outcome=$outcome`, async ({ optimistic, outcome }) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [], [ @@ -272,6 +295,7 @@ it.each( it.each([false, true])( `settles an earlier pending manual request after a later edit starts, success=%s`, async (success) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [], [ @@ -307,6 +331,7 @@ it.each([false, true])( it.each([false, true])( `consumes truncate attribution before a later same-key transaction in one drain, later=%s`, async (later) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -351,11 +376,12 @@ it.each([false, true])( // A truncate consumes attribution for keys it writes, while an active edit on // another key still owns its first later source transaction in the same drain. -// The nonoptimistic lane exposes source origin at the drain cut; the optimistic -// lane exposes it when the failed edit drops its overlay. +// The nonoptimistic production path exposes source origin at the drain cut; the optimistic +// production path exposes it when the failed edit drops its overlay. it.each([false, true])( `retains active attribution for another key after truncate, optimistic=%s`, async (optimistic) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [], [ @@ -419,6 +445,7 @@ it.each(overlappingCases)( sourceTransactions, settleSecondFirst, }) => { + oracleRoute(`case`) const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -465,6 +492,7 @@ it.each([ `previous-value`, `update-as-insert`, ] as const)(`rejects captured callback corruption: %s`, async (fault) => { + oracleRoute(`calibration`) // Rollback then queued source drain supplies two distinct complete cuts. await runOptimisticHistory(initial, history(false)) await expect( @@ -478,6 +506,7 @@ it.each([ // and a later active edit covers the key while a sync transaction waits. // The key must publish once when the edit's overlay and the sync apply. it(`publishes a key once after a settled delete beneath a later active edit`, async () => { + oracleRoute(`fixed history`) await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ diff --git a/packages/db/tests/optimistic-settlement-boundaries-oracle.test.ts b/packages/db/tests/optimistic-settlement-boundaries-oracle.test.ts deleted file mode 100644 index 5a275c1bc3..0000000000 --- a/packages/db/tests/optimistic-settlement-boundaries-oracle.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { it } from 'vitest' -import { runOptimisticHistory } from './optimistic-history-oracle.js' -import type { HistoryRow, OptimisticStep } from './optimistic-history-oracle.js' - -const row: HistoryRow = { id: 1, a: 0, b: 0, c: 0 } -const edit = ( - fields: Partial>, - optimistic = true, -): OptimisticStep => ({ type: `edit`, key: 1, fields, optimistic }) -const settle = (slot: number, success = true): OptimisticStep => ({ - type: `settle`, - slot, - success, - cascade: false, -}) -const sync = ( - rows: Array, - truncate = false, - copies = 1, -): OptimisticStep => ({ type: `sync`, rows, truncate, copies }) - -// Every regression is a program for the same model, driver and checkpoint -// assertions used by generated histories. Membership work has its own generated -// law in query/derived-delete-reconciliation-oracle.test.ts. -const cases: Array<{ - name: string - initial: Array - steps: Array -}> = [ - { - name: `one confirmation for repeated queued writes`, - initial: [row], - steps: [edit({ a: 1 }), sync([{ ...row, a: 1 }], false, 2), settle(0)], - }, - { - name: `an acknowledged insert cannot remove an accepted update`, - initial: [], - steps: [ - edit({ a: 1 }), - sync([{ ...row, a: 1 }], false), - edit({ b: 2 }), - settle(1), - settle(0, false), - ], - }, - { - name: `failed insertion cannot remove later same-key snapshots`, - initial: [], - steps: [ - edit({ a: 1 }), - edit({ b: 1 }), - settle(1), - settle(0, false), - edit({ a: 2 }), - edit({ b: 2 }), - settle(1), - settle(0), - ], - }, - ...[true, false].map((success) => ({ - name: `dependent snapshot follows insert settlement: ${success}`, - initial: [], - steps: [edit({ a: 1 }), edit({ b: 2 }), settle(1), settle(0, success)], - })), - ...[true, false].map((truncate) => ({ - name: `failed birth does not erase accepted sibling attribution: ${truncate}`, - initial: [], - steps: [ - edit({ a: 1 }), - edit({ b: 2 }), - settle(1), - settle(0, false), - sync([row], truncate), - ], - })), - { - name: `a later accepted update survives an earlier failed insertion`, - initial: [], - steps: [edit({ a: 1 }), edit({ b: 2 }), settle(0, false), settle(0)], - }, - ...[true, false].map((truncate) => ({ - name: `sync retirement precedes rebuilding active snapshots: ${truncate}`, - initial: [], - steps: [ - edit({ a: 1 }), - edit({ b: 2 }), - settle(1), - sync([], truncate), - settle(0, false), - ], - })), - { - name: `unchanged persistence completion does not publish`, - initial: [row], - steps: [edit({ a: 1 }), settle(0)], - }, - { - name: `truncate preserves the captured whole row`, - initial: [row], - steps: [edit({ a: 1 }), sync([{ ...row, b: 2 }], true), settle(0)], - }, - { - name: `rollback does not rewrite a later captured request`, - initial: [row], - steps: [edit({ a: 1 }), edit({ b: 2 }), settle(0, false), settle(0)], - }, - { - name: `duplicate writes retain a local acknowledgement within a batch`, - initial: [], - steps: [edit({ a: 1 }, false), sync([{ ...row, a: 1 }], false, 2)], - }, - { - name: `a persisting peer does not hide rollback publication`, - initial: [], - steps: [edit({ a: 1 }, false), edit({ a: 2 }), sync([]), settle(1, false)], - }, - { - name: `truncate retains pending nonoptimistic attribution`, - initial: [], - steps: [ - edit({ a: 1 }, false), - sync([], true), - sync([{ ...row, a: 1 }], false), - ], - }, - { - name: `completed sibling does not erase active truncate attribution`, - initial: [], - steps: [ - edit({ a: 1 }, false), - edit({ a: 2 }), - settle(1), - sync([{ ...row, a: 1 }], true), - ], - }, - { - name: `removal of a truncate-retained snapshot reaches subscribers`, - initial: [], - steps: [edit({ a: 1 }), settle(0), sync([], true), sync([])], - }, -] - -it.each(cases)(`oracle replay: $name`, async ({ initial, steps }) => { - await runOptimisticHistory(initial, steps) -}) diff --git a/packages/db/tests/optimistic-transaction-oracle.property.test.ts b/packages/db/tests/optimistic-transaction-oracle.property.test.ts index 3d3d3add79..8f499f2ceb 100644 --- a/packages/db/tests/optimistic-transaction-oracle.property.test.ts +++ b/packages/db/tests/optimistic-transaction-oracle.property.test.ts @@ -10,7 +10,13 @@ import { observeHistoryPromise, withHistoryCleanup, } from './optimistic-history-oracle.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { createOracleReach } from './oracle-reach.js' import type { Collection } from '../src/collection/index.js' @@ -75,7 +81,7 @@ const orders: Array> = [ // value (1..100) and note (0..10 chars). Removing order loses payload-order // checks; removing visibility loses the draft cut; removing outcome loses a // settlement branch; removing either field variation hides truncated payloads. -// Repeated operations are checked in the same-key lane below. Deleting an +// Repeated operations are checked in the same-key grammar below. Deleting an // absent key is excluded from this mixed-key grammar. const sameKeySequences = [ @@ -267,6 +273,7 @@ describe(`Same-key transaction laws`, () => { it.each([true, false])( `cancels a delete and a reinsert that restores the row (success=%s)`, async (success) => { + oracleRoute(`case`) await runSameKey({ sequence: `delete-insert`, value: 0, @@ -278,6 +285,7 @@ describe(`Same-key transaction laws`, () => { it.each(sameKeySequences)( `preserves authored sequence %s`, async (sequence) => { + oracleRoute(`case`) for (const success of [false, true]) await runSameKey({ sequence, success, value: 7, note: `last` }) }, @@ -290,6 +298,11 @@ describe(`Same-key transaction laws`, () => { [`sign-only-reinsert`], { minimum: 3 }, ) + const parameters = + seed === undefined + ? oraclePropertyOptions(40, `collection-state.same-key`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(40) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.oneof( @@ -334,9 +347,7 @@ describe(`Same-key transaction laws`, () => { return runSameKey(scenario) }, ), - seed === undefined - ? oraclePropertyOptions(40, `collection-state.same-key`) - : { numRuns: oracleRuns(40), seed }, + parameters, ) reach.assertReached() }, @@ -344,6 +355,7 @@ describe(`Same-key transaction laws`, () => { it.each([`dropped-field`, `canceled-persistence`] as const)( `rejects %s`, async (fault) => { + oracleRoute(`calibration`) const scenario: SameKeyScenario = { sequence: fault === `dropped-field` ? `update-update` : `insert-delete`, success: true, @@ -452,6 +464,7 @@ async function runDeleteInsertReplacement( describe(`Delete then insert transaction laws`, () => { it(`cancels an exact same-key restoration`, async () => { + oracleRoute(`fixed history`) await runDeleteInsertReplacement( [{ id: 1, value: 0, note: `original` }], undefined, @@ -486,6 +499,7 @@ describe(`Delete then insert transaction laws`, () => { }>)( `converts a $name to one symmetric update`, async ({ replacement, changes }) => { + oracleRoute(`case`) await runDeleteInsertReplacement([replacement], { type: `update`, original: { id: 1, value: 0, note: `original` }, @@ -502,6 +516,7 @@ describe(`Delete then insert transaction laws`, () => { ) it(`reduces repeated delete and insert pairs to the final net update`, async () => { + oracleRoute(`fixed history`) const first = { id: 1, value: 1, note: `first` } const final = { id: 1, value: 2, added: `final` } await runDeleteInsertReplacement([first, final], { @@ -519,6 +534,7 @@ describe(`Delete then insert transaction laws`, () => { }) it(`preserves the first preimage through duplicate deletes before replacement`, async () => { + oracleRoute(`fixed history`) const original = { id: 1, value: 0, note: `original` } const replacement = { id: 1, value: 2, note: `original` } await runDeleteInsertReplacement( @@ -549,6 +565,7 @@ describe(`Delete then insert transaction laws`, () => { }) it(`compares nested replacement values structurally`, async () => { + oracleRoute(`fixed history`) const original = { id: 1, value: 0, @@ -579,6 +596,7 @@ describe(`Delete then insert transaction laws`, () => { }) it(`delivers a replacement that differs only by an enumerable symbol`, async () => { + oracleRoute(`fixed history`) const field = Symbol(`replacement field`) const original = { id: 1, value: 0, [field]: 0 } const replacement = { id: 1, value: 0, [field]: 1 } @@ -604,6 +622,7 @@ describe(`Delete then insert transaction laws`, () => { }) it(`uses own-string key order for a symmetric replacement diff`, async () => { + oracleRoute(`fixed history`) const original = { 10: `remove numeric`, 2: `stable numeric`, @@ -1020,11 +1039,17 @@ describe(`Whole mixed transaction publication`, () => { ), ) it.each(cells)(`publishes complete transaction cuts %j`, async (cell) => { + oracleRoute(`case`) await runTransaction({ ...cell, value: 17, note: `new row` }) }) it.each([505202, undefined])( `varies full payloads, seed=%s`, async (seed) => { + const campaignParameters = + seed === undefined + ? oraclePropertyOptions(100, `collection-state.mixed-transaction`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -1040,9 +1065,7 @@ describe(`Whole mixed transaction publication`, () => { }), (scenario) => runTransaction(scenario), ), - seed === undefined - ? oraclePropertyOptions(100, `collection-state.mixed-transaction`) - : { numRuns: oracleRuns(100), seed }, + campaignParameters, ) }, ) @@ -1057,6 +1080,7 @@ describe(`Whole mixed transaction publication`, () => { ] as const)( `rejects captured %s through the real transaction driver's checks`, async (fault) => { + oracleRoute(`calibration`) const scenario: Scenario = { order: [`insert`, `update`, `delete`], optimistic: true, @@ -1090,7 +1114,7 @@ function expectedTypedKeyPayload( // expected payload is the authored key list, including multiplicity and order; // no encoded production globalKey participates in the comparison. Cross each // same-text pair with inserts, updates, deletes, repeated updates, and rollback. -// Existing same-key lanes still require genuine repeated keys to collapse. +// Existing same-key grammars still require genuine repeated keys to collapse. for (const { numberKey, reversed } of [0, -1, 1, 1.5].flatMap((key) => [false, true].map((reverseOrder) => ({ numberKey: key, @@ -1100,6 +1124,7 @@ for (const { numberKey, reversed } of [0, -1, 1, 1.5].flatMap((key) => for (const operation of [`insert`, `update`, `delete`, `repeat`] as const) { for (const success of [true, false]) { it(`keeps typed ${numberKey} keys distinct during ${operation}, success=${success}, reversed=${reversed}`, async () => { + oracleRoute(`case`) type TypedRow = { id: string | number; value: number } const keys = reversed ? [String(numberKey), numberKey] @@ -1187,10 +1212,14 @@ for (const { numberKey, reversed } of [0, -1, 1, 1.5].flatMap((key) => } } +// What this oracle owns: the gap report judges the transaction's authored +// payload and its same-key merge, and the Collection's mutation intake, by this +// file's tests. // The check drivers run one history and compare its public observations with // the expected world they compute from the scenario. The typed-key payload model // is the authored key list. declareOracle({ + owns: [`src/transactions.ts`, `src/collection/mutations.ts`], check: [runTransaction, runSameKey, runDeleteInsertReplacement], model: [expectedTypedKeyPayload], }) diff --git a/packages/db/tests/oracle-campaign.test.ts b/packages/db/tests/oracle-campaign.test.ts deleted file mode 100644 index 90ca4b6b8a..0000000000 --- a/packages/db/tests/oracle-campaign.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import { runOracleCampaign } from './oracle-campaign' - -afterEach(() => { - vi.unstubAllEnvs() - vi.restoreAllMocks() -}) - -it(`does not let fixed campaign reach conceal an unreached random state`, async () => { - vi.spyOn(console, `info`).mockImplementation(() => {}) - let campaigns = 0 - await expect( - runOracleCampaign(`TEST_ORACLE`, [`required`], (reach) => { - campaigns++ - reach.beginHistory() - if (campaigns === 1) reach.reach(`required`) - }), - ).rejects.toThrow(/required \(0\)/) - expect(campaigns).toBe(2) -}) - -it(`replays the requested seed and path without demanding unrelated states`, async () => { - vi.spyOn(console, `info`).mockImplementation(() => {}) - vi.stubEnv(`TEST_ORACLE_SEED`, `0`) - vi.stubEnv(`TEST_ORACLE_PATH`, `2:0`) - const parameters: Array<{ seed?: number; path?: string; numRuns?: number }> = - [] - await runOracleCampaign(`TEST_ORACLE`, [`unrelated`], (reach, value) => { - parameters.push(value) - reach.beginHistory() - }) - expect(parameters).toEqual([{ seed: 0, path: `2:0`, numRuns: 1 }]) -}) - -it(`keeps package reach enforcement independent of a DB replay selector`, async () => { - vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, `1`) - await expect( - runOracleCampaign(`TEST_ORACLE`, [`required`], (reach) => { - reach.beginHistory() - }), - ).rejects.toThrow(/required \(0\)/) -}) - -it(`rejects a shrink path without a seed before starting a campaign`, async () => { - vi.stubEnv(`TEST_ORACLE_PATH`, `0`) - const run = vi.fn() - await expect( - runOracleCampaign(`TEST_ORACLE`, [`state`], run), - ).rejects.toThrow(/requires a seed/) - expect(run).not.toHaveBeenCalled() -}) diff --git a/packages/db/tests/oracle-campaign.ts b/packages/db/tests/oracle-campaign.ts index c5bfafa96e..72f925f610 100644 --- a/packages/db/tests/oracle-campaign.ts +++ b/packages/db/tests/oracle-campaign.ts @@ -1,78 +1,7 @@ -import { oracleCampaignSeed } from './oracle-config' -import { createOracleReach } from './oracle-reach' +// The oracle framework lives in @tanstack/oracles. This module keeps the import +// path that TanStack DB's oracles use. It imports oracle-config first, so the +// framework runs under TanStack DB's configuration (its environment prefix and +// property registry) whichever name a test imports. +import './oracle-config.js' -/** - * Run the same oracle with separate fixed/random reach ledgers. The callback - * owns its property, grammar, recorder, comparison, and run budget; only seed - * selection changes between the two calls. Every started campaign reports its - * seed and counts even when the callback or reach assertion fails. Counts are histories, not events: - * call beginHistory once and reach only after observing the named checkpoint. - * A prefix SEED replay runs one campaign at the callback's full budget, and - * checks its reach. A SEED/PATH replay runs one recorded history and checks - * no reach. `declared-reach-oracle.property.test.ts` owns this rule. - * This is runner support, not an independent product judgment. - */ -export async function runOracleCampaign( - prefix: string, - states: ReadonlyArray, - run: ( - reach: ReturnType>, - parameters: { seed?: number; path?: string; numRuns?: number }, - ) => void | Promise, - { fixedSeed = 20261022 }: { fixedSeed?: number } = {}, -): Promise { - const seedText = process.env[`${prefix}_SEED`] - const path = process.env[`${prefix}_PATH`] - const seed = seedText === undefined ? undefined : Number(seedText) - if ( - seedText !== undefined && - (seedText.trim() === `` || !Number.isSafeInteger(seed)) - ) { - throw new Error(`${prefix}_SEED must be an integer`) - } - if ( - path !== undefined && - (seed === undefined || !/^\d+(?::\d+)*$/.test(path)) - ) - throw new Error(`${prefix}_PATH requires a seed and numeric shrink path`) - - const recordedReplay = { seed, counterexamplePath: path } - const campaigns = - seed === undefined - ? [ - { name: `fixed`, parameters: { seed: fixedSeed } }, - { name: `random`, parameters: {} }, - ] - : [ - { - name: `replay`, - parameters: - path === undefined - ? { seed } - : { seed, path: recordedReplay.counterexamplePath, numRuns: 1 }, - }, - ] - for (const campaign of campaigns) { - const reach = createOracleReach(prefix, states, { - replaying: path !== undefined, - }) - const parameters = { - ...campaign.parameters, - seed: oracleCampaignSeed(campaign.parameters.seed, prefix), - } - try { - await run(reach, parameters) - reach.assertReached() - } finally { - console.info( - `ORACLE_REACH ${JSON.stringify({ - property: prefix, - campaign: campaign.name, - seed: parameters.seed, - minimum: path === undefined ? 1 : null, - counts: Object.fromEntries(reach.counts()), - })}`, - ) - } - } -} +export * from '@tanstack/oracles' diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index ff892da1e2..6279b56a7a 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -1,8 +1,15 @@ -import { oracleReplayReporter } from './oracle-replay-witness.js' +// TanStack DB's oracle configuration. The oracle framework lives in +// @tanstack/oracles. This module supplies what is specific to TanStack DB: its +// registry of property ids, the replay variables that earlier versions read +// and that now fail, and the TANSTACK_DB_ORACLE prefix of its environment +// variables. Importing this module configures the framework, so every shim +// that re-exports the framework imports it first. +import { + configureOracles, + validateOraclePropertyRegistry, +} from '@tanstack/oracles' -export { recordOracleReplay } from './oracle-replay-witness.js' - -type OracleEnvironment = Record +export * from '@tanstack/oracles' const staticOracleProperties = [ `query.public-container-copy`, @@ -28,6 +35,14 @@ const staticOracleProperties = [ `ivm.topk-negative-window-array`, `ivm.topk-negative-window-grouped`, `ivm.topk-negative-window-orderBy`, + `ivm.topk-cyclic-payloads-array`, + `ivm.topk-cyclic-payloads-grouped`, + `ivm.topk-outer-join-replacements-array`, + `ivm.topk-outer-join-replacements-grouped`, + `notification-laws.query-publications`, + `notification-laws.publication-histories`, + `notification-laws.corrupt-prior-values`, + `proxy.json-data-keys`, `framework.react.window-history`, `framework.react.pending-window-history`, `framework.react.publication-window-history`, @@ -95,10 +110,9 @@ const staticOracleProperties = [ `react.window-render`, `react.window-retry-ownership`, `preact.window-render`, - `lint.part-reach`, - `oracle.declared-reach`, `scripts.lint-scope`, `scripts.run-budget`, + `scripts.strip-oracle-probes`, `scripts.release-version`, `scripts.release-seed-failure`, `react.ssr-effects-react18-ordinary-provider`, @@ -254,6 +268,8 @@ const staticOracleProperties = [ `sqlite-persistence.open-transaction-boundary`, `sqlite-persistence.request-local-failure-buffered-source`, `sqlite-persistence.optimistic-history`, + `sqlite-persistence.setup-reopen`, + `adapter.handler-types`, `adapter-conformance.eager-live-source.reference`, `adapter-conformance.eager-live-source.neon-realtime`, `adapter-conformance.eager-live-source.trailbase`, @@ -356,6 +372,10 @@ const staticOracleProperties = [ `pooled-live-query.publication`, `flat-change-tracking.equivalence`, `local-storage.value-fidelity`, + `local-storage.peers`, + `local-storage.write-order`, + `live-query-observer.hydration-terminal`, + `optimistic-history.core`, `derived-publication.membership-work`, `collection-publication.metadata-cancellation`, `collection-publication.metadata-only`, @@ -599,19 +619,6 @@ for (const depth of [1, 2, 3, 4]) { } } -export function validateOraclePropertyRegistry( - properties: ReadonlyArray, -): ReadonlySet { - const registry = new Set() - for (const property of properties) { - if (registry.has(property)) { - throw new Error(`duplicate oracle property: ${property}`) - } - registry.add(property) - } - return registry -} - export const registeredOracleProperties = validateOraclePropertyRegistry([ ...staticOracleProperties, ...publicationProperties, @@ -621,18 +628,6 @@ export const registeredOracleProperties = validateOraclePropertyRegistry([ ...includesMatrixProperties, ]) -function assertRegisteredOracleProperty(property: string): void { - if (!registeredOracleProperties.has(property)) { - throw new Error(`unknown oracle property: ${property}`) - } -} - -export type OracleReplayConfig = { - replaySeed: number | undefined - replayPath: string | undefined - 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 @@ -734,281 +729,8 @@ const retiredReplayVariables = [ `TANSTACK_DB_LIKE_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 to select a replay, and ` + - `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER to scale run counts.`, - ) - } - const multiplierValue = environment.TANSTACK_DB_ORACLE_RUNS_MULTIPLIER ?? `1` - const multiplier = Number(multiplierValue) - if ( - multiplierValue.trim() === `` || - !Number.isSafeInteger(multiplier) || - multiplier < 1 - ) { - throw new Error( - `TANSTACK_DB_ORACLE_RUNS_MULTIPLIER must be a positive integer`, - ) - } - - const seedValue = environment.TANSTACK_DB_ORACLE_SEED - const replayPath = environment.TANSTACK_DB_ORACLE_PATH - const replayProperty = environment.TANSTACK_DB_ORACLE_PROPERTY - if (seedValue === undefined) { - if (replayPath !== undefined) { - throw new Error( - `TANSTACK_DB_ORACLE_PATH requires TANSTACK_DB_ORACLE_SEED`, - ) - } - if (replayProperty !== undefined) { - throw new Error( - `TANSTACK_DB_ORACLE_PROPERTY requires TANSTACK_DB_ORACLE_PATH`, - ) - } - return { - multiplier, - replaySeed: undefined, - replayPath: undefined, - replayProperty: undefined, - } - } - - const replaySeed = Number(seedValue) - if (seedValue.trim() === `` || !Number.isSafeInteger(replaySeed)) { - throw new Error(`TANSTACK_DB_ORACLE_SEED must be an integer`) - } - if (replayPath === undefined) { - if (replayProperty !== undefined) { - throw new Error( - `TANSTACK_DB_ORACLE_PROPERTY requires TANSTACK_DB_ORACLE_PATH`, - ) - } - return { - multiplier, - replaySeed, - replayPath: undefined, - replayProperty: undefined, - } - } - if (replayPath.trim() === ``) { - throw new Error(`TANSTACK_DB_ORACLE_PATH must be non-empty`) - } - if (!/^\d+(?::\d+)*$/.test(replayPath)) { - throw new Error( - `TANSTACK_DB_ORACLE_PATH must contain colon-separated nonnegative integers`, - ) - } - if (replayProperty === undefined || replayProperty.trim() === ``) { - throw new Error( - `TANSTACK_DB_ORACLE_PATH requires TANSTACK_DB_ORACLE_PROPERTY`, - ) - } - assertRegisteredOracleProperty(replayProperty) - return { multiplier, replaySeed, replayPath, replayProperty } -} - -/** - * Select a fresh campaign seed unless the caller supplied one. Report the - * selection before fast-check runs: a later reach failure is outside its - * reporter and must still be replayable. This line records selection, not - * execution; guarded replay witnesses remain the execution evidence. - */ -export function oracleCampaignSeed( - seed: number | undefined, - property: string | undefined, -): number { - const selected = seed ?? Math.floor(Math.random() * 0x100000000) - 0x80000000 - console.info( - `ORACLE_SEED ${JSON.stringify({ property: property ?? null, seed: selected })}`, - ) - return selected -} - -type OracleRunParameters = { - numRuns: number - seed?: number - path?: string -} & ReturnType - -// fast-check infers a property's argument types from `examples` too, so only -// a call with examples returns them. `ReturnType` reads the last overload, -// the one without examples. -export function oracleRandomParameters( - numRuns: number, - replay: OracleReplayConfig | number | undefined, - property: string | undefined, - examples: ReadonlyArray, -): OracleRunParameters & { examples: Array } -export function oracleRandomParameters( - numRuns: number, - replay: OracleReplayConfig | number | undefined, - property?: string, -): OracleRunParameters -export function oracleRandomParameters( - numRuns: number, - replay: OracleReplayConfig | number | undefined, - property?: string, - examples?: ReadonlyArray, -): OracleRunParameters & { examples?: Array } { - return randomParameters(numRuns, replay, property, examples) -} - -function randomParameters( - numRuns: number, - replay: OracleReplayConfig | number | undefined, - property: string | undefined, - examples: ReadonlyArray | undefined, -): OracleRunParameters & { examples?: Array } { - if (property !== undefined) assertRegisteredOracleProperty(property) - const { replaySeed, replayPath, replayProperty } = - typeof replay === `object` - ? replay - : { - replaySeed: replay, - replayPath: undefined, - replayProperty: undefined, - } - const selectedSeed = oracleCampaignSeed(replaySeed, property) - const parameters: OracleRunParameters = - replaySeed === undefined - ? { numRuns, seed: selectedSeed } - : { - 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), - } - : {}), - } - if (examples === undefined) return parameters - // fast-check runs the examples first and counts them in numRuns, so they - // come on top of the random runs: the examples never displace one. A path - // replay runs one recorded history, which can be an example. - return { - ...parameters, - examples: [...examples], - numRuns: - parameters.path === undefined - ? parameters.numRuns + examples.length - : parameters.numRuns, - } -} - -const { multiplier, ...replay } = readOracleRunConfig() - -/** - * 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 -} - -/** - * 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 -} - -export { declareOracle } from './oracle-declaration.js' - -/** Keeps ordinary CI bounded while allowing long randomized oracle campaigns. */ -export function oracleRuns(baseRuns: number): number { - return baseRuns * multiplier -} - -/** - * Replays broad randomized properties when a campaign seed is supplied. - * `examples` run first in every run, on top of the base runs. A site that - * spreads these options and sets fast-check's `examples` itself runs them - * inside the base runs instead, so they displace random histories. - */ -export function oraclePropertyOptions( - baseRuns: number, - property: string | undefined, - options: { examples: ReadonlyArray }, -): OracleRunParameters & { examples: Array } -export function oraclePropertyOptions( - baseRuns: number, - property?: string, -): OracleRunParameters -export function oraclePropertyOptions( - baseRuns: number, - property?: string, - { examples }: { examples?: ReadonlyArray } = {}, -): OracleRunParameters & { examples?: Array } { - return randomParameters(oracleRuns(baseRuns), replay, property, examples) -} +configureOracles({ + envPrefix: `TANSTACK_DB_ORACLE`, + registry: registeredOracleProperties, + retiredVariables: retiredReplayVariables, +}) diff --git a/packages/db/tests/oracle-declaration.ts b/packages/db/tests/oracle-declaration.ts index f56143d90e..316760a525 100644 --- a/packages/db/tests/oracle-declaration.ts +++ b/packages/db/tests/oracle-declaration.ts @@ -1,31 +1,4 @@ -// declareOracle has no imports, so a module that runs in a browser or a -// native app can declare its parts without loading the Node-only replay -// machinery in oracle-config.ts. - -/** A function or class that an oracle declares as one of its parts. */ -type OraclePart = - ((...args: never) => unknown) | (abstract new (...args: never) => unknown) - -/** - * Names the parts of an oracle that this module defines: its check, which - * compares production observations with the model or with a second - * formulation; its model; and its history grammar, the generators that - * produce legal histories. A type-level oracle names its model types in - * `typeModel`. Each is optional: a differential or metamorphic - * oracle has no model, and a companion module can hold only one part. - * - * Every test in an oracle file must use a declared part, directly or through - * a driver. A test that uses none compares production with a hand-written - * value: it is a unit test, and belongs in an ordinary test file. The - * `oracle/no-unit-test` lint rule reads this call. It does nothing at run - * time. - */ -export function declareOracle(_parts: { - check?: ReadonlyArray - model?: ReadonlyArray - // A grammar part is often a fast-check arbitrary, not a function. - grammar?: ReadonlyArray - // A type-level oracle's model is a type, which has no value to pass, so it - // is named: each name is a type alias or interface in the same file. - typeModel?: ReadonlyArray -}): void {} +// declareOracle lives in @tanstack/oracles/declaration, which loads no Node +// built-ins. Browser and native tests declare their oracle's parts through this +// path, so it does not import the Node-only replay machinery. +export * from '@tanstack/oracles/declaration' diff --git a/packages/db/tests/oracle-failure-fidelity.ts b/packages/db/tests/oracle-failure-fidelity.ts index d67831cf12..72f925f610 100644 --- a/packages/db/tests/oracle-failure-fidelity.ts +++ b/packages/db/tests/oracle-failure-fidelity.ts @@ -1,138 +1,7 @@ -/** - * Preserve an oracle's first violated law and checkpoint while fast-check - * reduces its input (oracle rubric ORC-010). These are test-harness facts, not - * production state. A different failure is a diagnostic, not a reproduction. - */ -export class OracleCheckpointFailure extends Error { - constructor( - readonly law: string, - readonly checkpoint: string, - cause: unknown, - ) { - super(`${law} at ${checkpoint}: ${String(cause)}`, { cause }) - this.name = `OracleCheckpointFailure` - } -} +// The oracle framework lives in @tanstack/oracles. This module keeps the import +// path that TanStack DB's oracles use. It imports oracle-config first, so the +// framework runs under TanStack DB's configuration (its environment prefix and +// property registry) whichever name a test imports. +import './oracle-config.js' -/** Mark a synchronous comparison; asynchronous driver actions remain separate. */ -export function atOracleCheckpoint( - law: string, - checkpoint: string, - compare: () => T, -): T { - try { - return compare() - } catch (error) { - throw new OracleCheckpointFailure(law, checkpoint, error) - } -} - -interface FailureEvidence { - fingerprint: string - trace: string - error: unknown -} - -// Cleanup and history wrappers put the primary failure first. Never select a -// matching secondary mismatch when an earlier, different failure interrupted it. -function primaryFingerprint(error: unknown): string { - if (error instanceof AggregateError && error.errors.length > 0) - return primaryFingerprint(error.errors[0]) - if (error instanceof OracleCheckpointFailure) - return JSON.stringify([`comparison`, error.law, error.checkpoint]) - // An initial setup/action failure must also stay red. Its exact diagnostic is - // its fingerprint; it cannot silently become a later product-law mismatch. - return JSON.stringify([ - `unmarked`, - typeof error, - error instanceof Error ? error.name : null, - error instanceof Error ? error.message : String(error), - ]) -} - -export class OracleFailureReport extends AggregateError { - constructor( - readonly original: FailureEvidence, - readonly reduced: FailureEvidence, - readonly nonReproducing: ReadonlyArray, - ) { - super([], `Oracle reduction reproduced the original failure`, { - cause: original.error, - }) - // Fast-check retains the last reproducing error. Its final attempted - // reduction can fail differently, so diagnostics must remain live after - // this report was thrown. Cause and the two reproducing traces stay fixed. - Object.defineProperties(this, { - errors: { - get: () => [ - original.error, - ...(reduced.error === original.error ? [] : [reduced.error]), - ...nonReproducing.map((failure) => failure.error), - ], - }, - message: { - get: () => - `Oracle reduction reproduced the original failure ${original.fingerprint}` + - `\nOriginal trace: ${original.trace}` + - `\nReduced trace: ${reduced.trace}` + - (nonReproducing.length - ? `\nNon-reproducing reductions: ${nonReproducing.map((failure) => failure.fingerprint).join(`; `)}` - : ``), - }, - }) - this.name = `OracleFailureReport` - } -} - -/** One instance belongs to one campaign, including its shrink attempts. */ -export function createOracleFailureFidelity( - describeHistory: (history: THistory) => string = (history) => - JSON.stringify(history), -) { - let original: FailureEvidence | undefined - let reduced: FailureEvidence | undefined - const nonReproducing: Array = [] - return { - async run( - history: THistory, - body: () => void | Promise, - ): Promise { - // Capture before production, model stepping, or cleanup can alter inputs. - const trace = describeHistory(history) - try { - await body() - } catch (error) { - const failure = { fingerprint: primaryFingerprint(error), trace, error } - original ??= failure - if (failure.fingerprint !== original.fingerprint) { - nonReproducing.push(failure) - // Accept only as a shrink candidate. The campaign is already red; - // this candidate supplies no successful product comparison evidence. - return - } - reduced = failure - throw new OracleFailureReport(original, failure, nonReproducing) - } - }, - async assert(campaign: () => Promise): Promise { - try { - await campaign() - } catch (error) { - if (original === undefined || reduced === undefined) throw error - // fast-check renders its retained error before the last rejected - // candidate. Finalize afterwards so its seed/path diagnostic and every - // non-reproducing reduction survive in the rendered failure as well. - const report = new OracleFailureReport( - original, - reduced, - nonReproducing, - ) - throw new AggregateError( - [error, report], - `${String(error)}\n${report.message}`, - { cause: error }, - ) - } - }, - } -} +export * from '@tanstack/oracles' diff --git a/packages/db/tests/oracle-property.ts b/packages/db/tests/oracle-property.ts new file mode 100644 index 0000000000..72f925f610 --- /dev/null +++ b/packages/db/tests/oracle-property.ts @@ -0,0 +1,7 @@ +// The oracle framework lives in @tanstack/oracles. This module keeps the import +// path that TanStack DB's oracles use. It imports oracle-config first, so the +// framework runs under TanStack DB's configuration (its environment prefix and +// property registry) whichever name a test imports. +import './oracle-config.js' + +export * from '@tanstack/oracles' diff --git a/packages/db/tests/oracle-reach.test.ts b/packages/db/tests/oracle-reach.test.ts deleted file mode 100644 index 5836dc705e..0000000000 --- a/packages/db/tests/oracle-reach.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createOracleReach } from './oracle-reach.js' - -// A replay selection in the environment would switch the reach check off, so -// these examples choose an ordinary run. -describe(`createOracleReach`, () => { - it(`fails for a declared state that no history entered`, () => { - const reach = createOracleReach(`example`, [`seen`, `unseen`] as const, { - replaying: false, - }) - reach.beginHistory() - reach.reach(`seen`) - expect(() => reach.assertReached()).toThrow(/unseen \(0\)/) - }) - - it(`counts a history once per state`, () => { - const reach = createOracleReach(`example`, [`state`] as const, { - minimum: 2, - replaying: false, - }) - reach.beginHistory() - reach.reach(`state`) - reach.reach(`state`) - expect(reach.counts().get(`state`)).toBe(1) - expect(() => reach.assertReached()).toThrow(/state \(1\)/) - reach.beginHistory() - reach.reach(`state`) - expect(() => reach.assertReached()).not.toThrow() - }) - - it(`rejects an undeclared state`, () => { - const reach = createOracleReach(`example`, [`state`] as const) - expect(() => reach.reach(`other` as unknown as `state`)).toThrow( - /undeclared reach state other/, - ) - }) -}) diff --git a/packages/db/tests/oracle-reach.ts b/packages/db/tests/oracle-reach.ts index ecf6e43b01..72f925f610 100644 --- a/packages/db/tests/oracle-reach.ts +++ b/packages/db/tests/oracle-reach.ts @@ -1,127 +1,7 @@ -import { readOracleRunConfig } from './oracle-config.js' +// The oracle framework lives in @tanstack/oracles. This module keeps the import +// path that TanStack DB's oracles use. It imports oracle-config first, so the +// framework runs under TanStack DB's configuration (its environment prefix and +// property registry) whichever name a test imports. +import './oracle-config.js' -/** - * Declared reach for a generated-history property. - * - * A law is enforced only in the states that generated histories reach. A - * property can pass any number of runs and still check nothing in the state - * its law is about, because the grammar, the driver, or the run budget never - * produces that state. Counting passing runs does not show reach. - * - * A property declares the states its laws depend on. Its driver calls - * `reach(state)` when a history enters one. A call for an undeclared state, or - * before the first `beginHistory()`, throws: it is a driver error, not a - * history. Options that cannot describe a reach throw at creation. After the - * property ran, `assertReached()` fails for every declared state that fewer - * than `minimum` histories entered. A history counts once per state, however - * often it enters it. - * - * A property that runs fixed examples first, through `oraclePropertyOptions`' - * `examples`, passes their number as `fixedHistories` and begins each - * example's history with `beginHistory({ fixed: true })`. The check then also - * fails for every declared state that no fixed history entered: that state's - * reach would depend on the seed (#2287). Fixedness belongs to the history, so - * other tests may share the reach. Shrinking can run an example again, so a - * fixed history beyond the declared number counts as fixed too. When fewer - * fixed histories ran than were declared, a failure stopped the run inside the - * examples, and the check reports only that, without naming states. - * - * A path replay checks one recorded history, not reach, so the check does - * nothing then, in every reach of that process: a reach need not carry its - * property's name, so it cannot tell whose replay runs. A seed alone reseeds - * the whole campaign, which runs its full budget, so its reach is checked like - * an ordinary run's. - * `packages/db/tests/declared-reach-oracle.property.test.ts` owns these rules. - */ -export function createOracleReach( - property: string, - states: ReadonlyArray, - { - minimum = 1, - fixedHistories = 0, - replaying: replayOverride, - }: { minimum?: number; fixedHistories?: number; replaying?: boolean } = {}, -) { - if (!Number.isInteger(minimum) || minimum < 1) { - throw new Error(`${property}: minimum must be an integer of at least 1`) - } - if (!Number.isInteger(fixedHistories) || fixedHistories < 0) { - throw new Error(`${property}: fixedHistories must be a nonnegative integer`) - } - if (new Set(states).size !== states.length) { - throw new Error(`${property}: a reach state is declared twice`) - } - const counts = new Map(states.map((state) => [state, 0])) - const enteredByFixed = new Set() - let histories = 0 - let fixedBegun = 0 - let currentFixed = false - let current = new Set() - // Other packages use their own replay prefixes. An explicit campaign choice - // must not inherit an unrelated DB replay selector from the environment. - const replaying = - replayOverride ?? readOracleRunConfig().replayPath !== undefined - return { - /** Starts one generated history, a fixed example's when `fixed`. */ - beginHistory({ fixed = false }: { fixed?: boolean } = {}): void { - if (fixed) fixedBegun += 1 - currentFixed = fixed - current = new Set() - histories += 1 - }, - /** The current history entered a declared state. */ - reach(state: TState): void { - if (!counts.has(state)) { - throw new Error(`${property}: undeclared reach state ${state}`) - } - if (histories === 0) { - throw new Error( - `${property}: reach(${state}) came before the first beginHistory()`, - ) - } - if (currentFixed) enteredByFixed.add(state) - if (current.has(state)) return - current.add(state) - counts.set(state, counts.get(state)! + 1) - }, - /** How many histories entered each declared state. */ - counts(): ReadonlyMap { - return counts - }, - /** Fails for each declared state that too few histories entered. */ - assertReached(): void { - if (replaying) return - if (fixedBegun < fixedHistories) { - throw new Error( - `${property}: only ${fixedBegun} of its ${fixedHistories} fixed ` + - `histories ran, so a failure stopped the run inside the fixed ` + - `examples`, - ) - } - const missed = [...counts] - .filter(([, count]) => count < minimum) - .map(([state, count]) => `${state} (${count})`) - const unfixed = - fixedHistories === 0 - ? [] - : states.filter((state) => !enteredByFixed.has(state)) - const failures = [ - ...(missed.length > 0 - ? [ - `${property}: generated histories reached these states fewer ` + - `than ${minimum} times, so their laws were not enforced ` + - `there: ${missed.join(`, `)}`, - ] - : []), - ...(unfixed.length > 0 - ? [ - `${property}: its ${fixedHistories} fixed histories, the ` + - `examples, did not enter these states, so their reach ` + - `depends on the seed: ${unfixed.join(`, `)}`, - ] - : []), - ] - if (failures.length > 0) throw new Error(failures.join(`\n`)) - }, - } -} +export * from '@tanstack/oracles' diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 2e7b2acf02..79d9c72486 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -123,17 +123,12 @@ const ownerGroups: ReadonlyArray< `ssr-effects-current-standalone ssr-effects-current-supplied`, ].join(` `), ], - [ - `../scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js`, - `lint`, - `part-reach`, - ], [`../scripts/run-budget-oracle.property.test.js`, `scripts`, `run-budget`], [`../scripts/lint-scope-oracle.property.test.js`, `scripts`, `lint-scope`], [ - `db/tests/declared-reach-oracle.property.test.ts`, - `oracle`, - `declared-reach`, + `../scripts/strip-oracle-probes-oracle.property.test.js`, + `scripts`, + `strip-oracle-probes`, ], [ `../scripts/release-history-oracle.property.test.js`, @@ -498,10 +493,20 @@ const ownerGroups: ReadonlyArray< `generation-notice`, ], [ - `db-sqlite-persistence-core/tests/persisted-optimistic-history.test.ts`, + `db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts`, `sqlite-persistence`, `optimistic-history`, ], + [ + `db-sqlite-persistence-core/tests/collection-setup-reopen-oracle.test.ts`, + `sqlite-persistence`, + `setup-reopen`, + ], + [ + `db/tests/mutation-handler-types-oracle.test.ts`, + `adapter`, + `handler-types`, + ], [ `db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts`, `sqlite-resume`, @@ -567,6 +572,12 @@ const ownerGroups: ReadonlyArray< `local-storage`, `value-fidelity`, ], + [`db/tests/local-storage-peers-oracle.test.ts`, `local-storage`, `peers`], + [ + `db/tests/local-storage-write-order-oracle.test.ts`, + `local-storage`, + `write-order`, + ], [ `db/tests/query/bucket-facade-rollback-oracle.property.test.ts`, `bucket-facade`, @@ -593,6 +604,31 @@ const ownerGroups: ReadonlyArray< `ordered-window`, ], [`db/tests/reverse-index-oracle.property.test.ts`, `reverse-index`, `reads`], + [ + `db/tests/optimistic-history-oracle.property.test.ts`, + `optimistic-history`, + `core`, + ], + [ + `db/tests/live-query-observer-hydration-oracle.property.test.ts`, + `live-query-observer`, + `hydration-terminal`, + ], + [ + `db/tests/notification-laws-oracle.test.ts`, + `notification-laws`, + `query-publications publication-histories corrupt-prior-values`, + ], + [ + `db/tests/proxy-detachment-contract-oracle.test.ts`, + `proxy`, + `json-data-keys`, + ], + [ + `db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts`, + `ivm`, + `topk-cyclic-payloads-array topk-cyclic-payloads-grouped topk-outer-join-replacements-array topk-outer-join-replacements-grouped`, + ], // Owners of the replays that moved to the shared variables. [ `browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts`, @@ -891,7 +927,7 @@ export type OracleReplayManifestEntry = { /** * `browser-replay` names a Playwright owner. The guarded Vitest runner * cannot witness it, so it rejects the target. Replay it with `command`. - * `seed-replay` names a property whose generator has no shrink path. A + * `reseeded-campaign` names a property whose generator has no shrink path. A * seed alone reproduces its history: `command` runs the owner directly * with TANSTACK_DB_ORACLE_SEED, which reseeds its random campaign. The * guarded runner, which witnesses a seed and path, rejects it. @@ -900,7 +936,7 @@ export type OracleReplayManifestEntry = { | `assertion` | `statistics-only` | `browser-replay` - | `seed-replay` + | `reseeded-campaign` | `no-named-owner` file?: string /** The module that names the property, when the owner file imports it. */ @@ -911,7 +947,7 @@ export type OracleReplayManifestEntry = { environment: ReadonlyArray } -// These patterns name the random/replay lane, never its fixed-seed sibling. +// These patterns name the random/replay campaign, never its fixed-seed sibling. // Pagination cells carry their property name in the test title so the guard // can select one cell without running the rest of the matrix. const replayTestNames = new Map([ @@ -1114,7 +1150,7 @@ const replayTestNames = new Map([ // Properties whose own seeded generator replaces fast-check, so they have no // shrink path. -const seedReplayProperties = new Set([`evaluators.like`]) +const reseededProperties = new Set([`evaluators.like`]) function directReplayTestName(property: string): string | undefined { if (property.startsWith(`pagination.limit-admission-`)) { @@ -1126,7 +1162,7 @@ function directReplayTestName(property: string): string | undefined { if (property.startsWith(`framework.`)) { switch (property.split(`.`).at(-1)) { case `result-shape`: - return `query projection histories \\(random\\)` + return `result-shape > random seed` case `window-history`: return `(?:direct|nested) window histories \\(fresh\\)` case `pending-window-history`: @@ -1195,8 +1231,8 @@ export const oracleReplayManifest: ReadonlyArray = [ ? `no-named-owner` : file.endsWith(`.spec.ts`) ? `browser-replay` - : seedReplayProperties.has(property) - ? `seed-replay` + : reseededProperties.has(property) + ? `reseeded-campaign` : property.endsWith(`-statistics`) || property.endsWith(`.scenario-statistics`) ? `statistics-only` @@ -1222,7 +1258,7 @@ export const oracleReplayManifest: ReadonlyArray = [ ? undefined : status === `browser-replay` ? `pnpm exec playwright test --config playwright.indexed-db.config.ts ${file.split(`/`).slice(2).join(`/`)}` - : status === `seed-replay` + : status === `reseeded-campaign` ? `pnpm exec vitest run ${file.split(`/`).slice(2).join(`/`)}` : outsidePackages ? `node --import tsx ${`../`.repeat(cwd!.split(`/`).length)}packages/db/tests/oracle-replay.ts ${outsideConfig === undefined ? `` : `--config ${outsideConfig} `}${file.split(`/`).pop()}` @@ -1232,7 +1268,7 @@ export const oracleReplayManifest: ReadonlyArray = [ ? [ `TANSTACK_DB_ORACLE_STATISTICS=1 (sampling only; no exact assertion replay)`, ] - : status === `seed-replay` + : status === `reseeded-campaign` ? [`TANSTACK_DB_ORACLE_SEED`] : [ `TANSTACK_DB_ORACLE_SEED`, diff --git a/packages/db/tests/oracle-replay-witness.ts b/packages/db/tests/oracle-replay-witness.ts index a6727ecc76..72f925f610 100644 --- a/packages/db/tests/oracle-replay-witness.ts +++ b/packages/db/tests/oracle-replay-witness.ts @@ -1,128 +1,7 @@ -import { appendFileSync } from 'node:fs' -import { asyncDefaultReportMessage, defaultReportMessage } from 'fast-check' -import type { RunDetails } from 'fast-check' +// The oracle framework lives in @tanstack/oracles. This module keeps the import +// path that TanStack DB's oracles use. It imports oracle-config first, so the +// framework runs under TanStack DB's configuration (its environment prefix and +// property registry) whichever name a test imports. +import './oracle-config.js' -export type OracleReplayWitness = { - property: string - seed: number - path: string | undefined - numRuns: number - failed: boolean -} - -function witnessChannel( - property: string, - seed: number, - path: string | undefined, -): string | undefined { - const channel = process.env.TANSTACK_DB_ORACLE_REPLAY_WITNESS - return channel !== undefined && - property === process.env.TANSTACK_DB_ORACLE_PROPERTY && - seed === Number(process.env.TANSTACK_DB_ORACLE_SEED) && - path === process.env.TANSTACK_DB_ORACLE_PATH - ? channel - : undefined -} - -function appendWitness( - channel: string, - property: string, - details: RunDetails, -): void { - const witness: OracleReplayWitness = { - property, - seed: details.seed, - path: details.runConfiguration.path, - numRuns: details.numRuns, - failed: details.failed, - } - appendFileSync(channel, `${JSON.stringify(witness)}\n`) -} - -/** - * Record a guarded replay witness for an owner that runs its property with - * `fc.check`. fast-check calls reporters only from `fc.assert`, so such an - * owner records its run details itself. It never throws: the owner keeps its - * own failure analysis. - */ -export function recordOracleReplay( - property: string, - details: RunDetails, -): void { - const channel = witnessChannel( - property, - details.seed, - details.runConfiguration.path, - ) - if (channel !== undefined) appendWitness(channel, property, details) -} - -/** A completed finite-history comparison has no fast-check RunDetails. */ -export function recordOracleFiniteReplay( - property: string, - { seed, path, failed }: { seed: number; path: string; failed: boolean }, -): void { - const channel = witnessChannel(property, seed, path) - if (channel === undefined) return - const witness: OracleReplayWitness = { - property, - seed, - path, - numRuns: 1, - failed, - } - appendFileSync(channel, `${JSON.stringify(witness)}\n`) -} - -/** - * Attach a witness only when the guarded runner's property, seed, and path all - * match. Constructing fast-check options is not evidence that a property ran. - * The reporter records fast-check's completed run details, then preserves its - * native failure formatting and cause policy instead of turning instrumentation - * into a second assertion engine. - */ -export function oracleReplayReporter( - property: string, - seed: number, - path: string, -): { - asyncReporter?: (details: RunDetails) => Promise - reporter?: (details: RunDetails) => void -} { - const channel = witnessChannel(property, seed, path) - if (channel === undefined) return {} - const record = (details: RunDetails) => - appendWitness(channel, property, details) - const rethrow = (details: RunDetails, message: string | undefined) => { - // A custom reporter replaces fast-check's throw. Preserve its public - // formatter and errorWithCause policy, including precondition failures. - if (message === undefined) return - const error = new Error(message) - if (details.runConfiguration.errorWithCause) { - Object.defineProperty(error, `cause`, { - value: details.errorInstance, - configurable: true, - writable: true, - }) - } - throw error - } - // fast-check accepts an async reporter only for an async property. When a - // synchronous property rejects it, the guarded runner retries in the - // synchronous mode, which uses fast-check's synchronous formatter. - if (process.env.TANSTACK_DB_ORACLE_REPLAY_WITNESS_MODE === `sync`) { - return { - reporter: (details) => { - record(details) - rethrow(details, defaultReportMessage(details)) - }, - } - } - return { - asyncReporter: async (details) => { - // Statistics sampling has no assertion reporter and cannot earn reach. - record(details) - rethrow(details, await asyncDefaultReportMessage(details)) - }, - } -} +export * from '@tanstack/oracles' diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index 5511b7e4f2..264732e211 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -5,7 +5,6 @@ import { dirname, join, resolve } from 'node:path' import { setImmediate } from 'node:timers/promises' import { fileURLToPath } from 'node:url' import { stripVTControlCharacters } from 'node:util' -import fc from 'fast-check' import { afterEach, describe, expect, it, vi } from 'vitest' import { oracleReplayManifest } from './oracle-replay-manifest.js' import { oracleReplayReporter } from './oracle-replay-witness.js' @@ -98,7 +97,7 @@ describe(`guarded oracle replay`, () => { expect(result.output).not.toContain(`unrelated fixed campaign ran`) }, 40_000) - it(`intersects a caller alternation with the named replay lane`, () => { + it(`intersects a caller alternation with the named replay campaign`, () => { const result = runReplay([ fixture, `-t`, @@ -157,7 +156,7 @@ describe(`guarded oracle replay`, () => { ), ), ] as const)( - `selects only the requested %s replay lane`, + `selects only the requested %s replay campaign`, (property, file, expectedTests) => { const result = runReplay([file], undefined, property) expect(result.status, result.output).toBe(0) @@ -344,7 +343,7 @@ 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, + campaignSeed: 42, replayPath: `3`, replayProperty: `oracle-replay.calibration`, } @@ -357,50 +356,43 @@ describe(`oracle replay reporting`, () => { }) }) - it.each([false, true])( - `preserves native async failure formatting and cause=%s`, - async (errorWithCause) => { - directory = mkdtempSync(join(tmpdir(), `oracle-reporter-test-`)) - const channel = join(directory, `witness.jsonl`) - vi.stubEnv(`TANSTACK_DB_ORACLE_REPLAY_WITNESS`, channel) - vi.stubEnv(`TANSTACK_DB_ORACLE_PROPERTY`, `oracle-replay.calibration`) - vi.stubEnv(`TANSTACK_DB_ORACLE_SEED`, `42`) - vi.stubEnv(`TANSTACK_DB_ORACLE_PATH`, `0`) - const sentinel = new Error(`native cause sentinel`) - const value = { - [fc.asyncToStringMethod]: () => Promise.resolve(`async replay value`), - } - const property = fc.asyncProperty(fc.constant(value), () => - Promise.reject(sentinel), - ) - const options = { seed: 42, path: `0`, numRuns: 1, errorWithCause } - async function failure(instrumented: boolean): Promise { - try { - await fc.assert(property, { - ...options, - ...(instrumented - ? oracleReplayReporter(`oracle-replay.calibration`, 42, `0`) - : {}), - }) - } catch (error) { - if (error instanceof Error) return error - throw error - } - throw new Error(`expected property failure`) - } - const native = await failure(false) - const instrumented = await failure(true) - expect(instrumented.message).toBe(native.message) - expect(instrumented.message).toContain(`async replay value`) - expect(Object.getOwnPropertyDescriptor(instrumented, `cause`)).toEqual( - Object.getOwnPropertyDescriptor(native, `cause`), - ) - expect(JSON.parse(readFileSync(channel, `utf8`))).toMatchObject({ - numRuns: 1, - failed: true, - }) - }, - ) + // fast-check 4 always attaches the property's error as the cause. The + // framework reads the replay environment once, at configuration, so the + // comparison runs in a child process that starts with that environment. + it(`preserves native async failure formatting and cause`, () => { + directory = mkdtempSync(join(tmpdir(), `oracle-reporter-test-`)) + const channel = join(directory, `witness.jsonl`) + const result = spawnSync( + process.execPath, + [ + `--import`, + `tsx`, + join(packageDirectory, `tests/replay-reporter-format.fixture.ts`), + ], + { + cwd: packageDirectory, + env: { + ...process.env, + TANSTACK_DB_ORACLE_REPLAY_WITNESS: channel, + TANSTACK_DB_ORACLE_PROPERTY: `oracle-replay.calibration`, + TANSTACK_DB_ORACLE_SEED: `42`, + TANSTACK_DB_ORACLE_PATH: `0`, + }, + encoding: `utf8`, + timeout: 30_000, + }, + ) + if (result.error) throw result.error + expect(result.status, result.stderr).toBe(0) + const { native, instrumented, witness } = JSON.parse( + result.stdout.trim().split(`\n`).at(-1)!, + ) + expect(instrumented.message).toBe(native.message) + expect(instrumented.message).toContain(`async replay value`) + expect(native.cause).toMatchObject({ sentinel: true }) + expect(instrumented.cause).toEqual(native.cause) + expect(witness).toMatchObject({ numRuns: 1, failed: true }) + }, 40_000) }) describe(`named oracle replay manifest`, () => { @@ -701,12 +693,12 @@ describe(`named oracle replay manifest`, () => { ).toEqual([]) }) - it(`replays a property without a shrink path from its seed alone`, () => { + it(`reseeds a property from its campaign seed alone`, () => { // The LIKE oracle draws its cases from its own seeded generator, so the // guarded runner, which witnesses a seed and a path, cannot witness it. expect( oracleReplayManifest - .filter(({ status }) => status === `seed-replay`) + .filter(({ status }) => status === `reseeded-campaign`) .map(({ property, command, environment }) => ({ property, command, diff --git a/packages/db/tests/oracle-replay.ts b/packages/db/tests/oracle-replay.ts index 3d0e11b37b..df0ff476ff 100644 --- a/packages/db/tests/oracle-replay.ts +++ b/packages/db/tests/oracle-replay.ts @@ -49,8 +49,8 @@ function selectReplayTest(args: Array, pattern: string): Array { forwarded.push(argument) } } - // The manifest selects the named random/replay lane. Caller filters can - // narrow that lane, but cannot replace it with a fixed or unrelated test. + // The manifest selects the named random or replay campaign. Caller filters can + // narrow that campaign, but cannot replace it with a fixed or unrelated test. const intersection = `^${filters .map((filter) => `(?=[\\s\\S]*(?:${filter}))`) .join(``)}` @@ -111,14 +111,14 @@ try { const reached = witnesses.filter( (witness) => witness.property === property && - witness.seed === config.replaySeed && + witness.seed === config.campaignSeed && witness.path === config.replayPath && witness.numRuns > 0, ) console.log( `Oracle replay: ${JSON.stringify({ property, - seed: config.replaySeed, + seed: config.campaignSeed, path: config.replayPath, owner: entry.file, command: [process.execPath, vitest, `run`, ...args], diff --git a/packages/db/tests/paced-mutations-oracle.test.ts b/packages/db/tests/paced-mutations-oracle.test.ts index a36267df89..70fd3e6bfd 100644 --- a/packages/db/tests/paced-mutations-oracle.test.ts +++ b/packages/db/tests/paced-mutations-oracle.test.ts @@ -13,6 +13,7 @@ import { origin, withCleanup, } from './paced-mutations-support' +import { oracleRoute } from './oracle-config.js' import type { Start } from './paced-mutations-support' import type { Strategy } from '../src/strategies/types' import type { Transaction } from '../src/transactions' @@ -320,7 +321,17 @@ function checkTimelineObservation(testCase: Case, actual: Observation): void { ) } +// What this oracle owns: the gap report judges the paced mutation queue and +// the debounce, throttle, and queue strategies that pace it, by this file's +// tests. declareOracle({ + owns: [ + `src/paced-mutations.ts`, + `src/strategies/debounceStrategy.ts`, + `src/strategies/throttleStrategy.ts`, + `src/strategies/queueStrategy.ts`, + `src/strategies/serial-pacer.ts`, + ], check: [checkTimelineObservation, checkHeldPacing], model: [ queueStarts, @@ -455,12 +466,14 @@ const cases: Array = [ describe(`paced mutation timeline oracle`, () => { for (const testCase of cases) { it(testCase.name, async () => { + oracleRoute(`case`) await runProduction(testCase, (actual) => checkTimelineObservation(testCase, actual), ) }) } it(`rejects front/back, trailing, and leading-edge mutants`, async () => { + oracleRoute(`calibration`) const wrongStrategies: Array<{ caseName: string strategy: () => Strategy @@ -496,6 +509,7 @@ describe(`paced mutation timeline oracle`, () => { } }) it(`rejects capacity, overflow settlement, early throttle, and option mutation mutants`, async () => { + oracleRoute(`calibration`) const capacityChecker = async (strategy: Strategy) => { const collection = await createReadyCollection() const mutate = createPacedMutations({ @@ -752,6 +766,7 @@ describe(`paced persistence hold/release oracle`, () => { for (const trailing of [true, undefined] as const) for (const releaseOffset of [-1, 0, 1, 10]) it(`${kind} leading ${String(leading)} trailing ${String(trailing)} release offset ${releaseOffset} preserves serial persistence and settlement`, async () => { + oracleRoute(`case`) await checkHeldPacing({ kind, leading, trailing, releaseOffset }) }) }) @@ -782,6 +797,7 @@ describe(`paced settlement and pending ownership oracle`, () => { for (const sameKey of [true, false]) for (const outcome of [`resolve`, `reject`, `cancel-pending`] as const) it(`${factory.name} leading ${leading} same key ${sameKey} with ${outcome} preserves transaction settlement`, async () => { + oracleRoute(`case`) const collection = await createReadyCollection([ { id: 1, value: 0 }, { id: 2, value: 0 }, diff --git a/packages/db/tests/production-error-messages-oracle.test.ts b/packages/db/tests/production-error-messages-oracle.test.ts index afb3124e23..afed5aeecf 100644 --- a/packages/db/tests/production-error-messages-oracle.test.ts +++ b/packages/db/tests/production-error-messages-oracle.test.ts @@ -77,6 +77,7 @@ import { errorSampleArguments } from './error-sample-arguments' import { exportedErrorClasses } from './production-error-messages-fixture' import { findErrorSites } from './error-sites-oracle' import { withErrorDocsFixture } from './generate-error-docs-fixture' +import { oracleRoute } from './oracle-config.js' const testsDirectory = dirname(fileURLToPath(import.meta.url)) const devMessages: Record> = JSON.parse( @@ -378,7 +379,12 @@ function renderErrorDocs(): string { }) } +// What this oracle owns: the gap report judges the error classes and the +// message builder that produce each error's development and production text, +// by this file's tests. The error sites that throw library text outside +// errors.ts are read from source by error-sites-oracle.ts. declareOracle({ + owns: [`src/errors.ts`, `src/error-message.ts`], model: [catalogObservation], check: [ expectEveryClassCoded, @@ -396,26 +402,32 @@ describe(`production error messages`, () => { afterEach(() => vi.unstubAllEnvs()) it(`codes every exported class except the caller-message bases`, () => { + oracleRoute(`case`) expectEveryClassCoded() }) it(`keeps the full message in production for full-text classes`, () => { + oracleRoute(`case`) expectFullTextInProduction() }) it(`keeps every development message unchanged`, () => { + oracleRoute(`case`) expectDevelopmentMessages() }) it(`keeps class, name, instanceof, and fields in both modes`, () => { + oracleRoute(`case`) expectClassShapeInBothModes() }) it(`writes a coded production line with the shown inputs`, () => { + oracleRoute(`case`) expectCodedProductionLines() }) it(`writes one coded line for hostile inputs`, () => { + oracleRoute(`case`) expectHostileInputsCoded() }) @@ -440,12 +452,14 @@ describe(`production error messages`, () => { const developmentOnlyFiles = new Set([`collection/config-errors.ts`]) it(`codes every site that throws library text`, () => { + oracleRoute(`case`) expect( sites.plain.filter(({ file }) => !developmentOnlyFiles.has(file)), ).toEqual([]) }) it(`keeps each site's development message and code`, () => { + oracleRoute(`case`) const current = Object.fromEntries( sites.coded.map(({ code, file, template, literals, kind }) => [ code, @@ -462,6 +476,7 @@ describe(`production error messages`, () => { }) it(`passes every showable interpolated value to the code line`, () => { + oracleRoute(`case`) for (const site of sites.coded) for (const interpolation of site.interpolations.filter( ({ showable }) => showable, @@ -481,6 +496,7 @@ describe(`production error messages`, () => { // Production lines never carry plain objects, such as rows. it(`passes only values that the code line can show`, () => { + oracleRoute(`case`) const hidden = sites.coded.flatMap(({ file, code, values }) => values .filter(({ showable }) => !showable) @@ -490,6 +506,7 @@ describe(`production error messages`, () => { }) it(`registers the code of every error class outside errors.ts`, () => { + oracleRoute(`case`) const codes: Record = JSON.parse( readFileSync(codesPath, `utf8`), ) @@ -503,14 +520,16 @@ describe(`production error messages`, () => { // 2026-10-08). Their text is frozen here, and the bundle check proves a // production bundle drops every frozen literal. it(`freezes every development-only literal`, () => { + oracleRoute(`case`) const frozenRegions: Array<{ file: string; literals: Array }> = JSON.parse(readFileSync(developmentOnlyPath, `utf8`)) expect(sites.developmentOnly).toEqual(frozenRegions) }) // The census is the classifier these laws rest on, so its rules are - // pinned on hand-written cases: one per rule, in fixtures/census. + // fixed on hand-written cases: one per rule, in fixtures/census. it(`classifies guarded, unguarded, and coded census cases`, () => { + oracleRoute(`case`) const cases = findErrorSites( resolve(testsDirectory, `fixtures/census/src`), ) @@ -540,6 +559,7 @@ describe(`production error messages`, () => { // The warning and sink laws read where a message is used, so a coded // message is used where it is written, not stored first. it(`uses every coded message where it is written`, () => { + oracleRoute(`case`) expect( sites.coded .filter(({ stored }) => stored) @@ -550,6 +570,7 @@ describe(`production error messages`, () => { // A warning says so (maintainer decision, 2026-10-08): a console.warn // message uses codedWarning, and nothing else does. it(`codes a console.warn message as a warning`, () => { + oracleRoute(`case`) const mismatched = sites.coded .filter(({ kind, inWarn }) => (kind === `warning`) !== inWarn) .map(({ file, code, kind }) => `${file} ${kind} ${code}`) @@ -560,6 +581,7 @@ describe(`production error messages`, () => { }) it(`never reuses a class code`, () => { + oracleRoute(`case`) const codes: Record = JSON.parse( readFileSync(codesPath, `utf8`), ) @@ -572,12 +594,14 @@ describe(`production error messages`, () => { // a symbol or bigint shows as a quoted string, and `undefined`, `NaN`, and // the infinities show as themselves rather than as `null` or nothing. it(`shows values that JSON cannot encode in a shown position`, () => { + oracleRoute(`case`) expectUnencodableValuesShown() }) it.each([`heading`, `class-anchor`, `site-anchor`] as const)( `rejects a missing generated %s`, (part) => { + oracleRoute(`calibration`) const docs = catalogObservation() expectCodesDocumented(docs) let wrong: string @@ -609,6 +633,7 @@ describe(`production error messages`, () => { ) it(`rejects class IDs inside canonical fenced message examples`, () => { + oracleRoute(`calibration`) const docs = catalogObservation() const codes: Record = JSON.parse( readFileSync(codesPath, `utf8`), @@ -621,6 +646,7 @@ describe(`production error messages`, () => { }) it(`documents every code with its class`, () => { + oracleRoute(`case`) const reach = createOracleReach( `production-errors.generated-docs`, [`cli-stdout-received`, `catalog-compared`], diff --git a/packages/db/tests/production-error-messages.test.ts b/packages/db/tests/production-error-messages.test.ts index 3693439939..c932b9edb7 100644 --- a/packages/db/tests/production-error-messages.test.ts +++ b/packages/db/tests/production-error-messages.test.ts @@ -1,5 +1,5 @@ /** - * Ordinary coverage pin for production error messages: every exported error + * Ordinary fixed-example coverage for production error messages: every exported error * class has sample inputs. The production-message law is owned by * production-error-messages-oracle.test.ts. */ diff --git a/packages/db/tests/proxy-detachment-contract-oracle.test.ts b/packages/db/tests/proxy-detachment-contract-oracle.test.ts index 8b72f57dfc..a55b6502d0 100644 --- a/packages/db/tests/proxy-detachment-contract-oracle.test.ts +++ b/packages/db/tests/proxy-detachment-contract-oracle.test.ts @@ -19,7 +19,13 @@ import { describe, expect, it, vi } from 'vitest' import fc from 'fast-check' import { withChangeTracking } from '../src/proxy.js' -import { oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { cycleContainer, cycleKinds, @@ -31,12 +37,19 @@ import { storedDataRow, storedRow, } from './proxy-detachment-fixture.js' +import { declareOracle } from './oracle-declaration.js' import type { CycleRow } from './proxy-detachment-fixture.js' +// What this oracle owns: the gap report judges the draft-to-stored-row copy in +// proxy.ts by this file's tests. The fixture helpers declare the checks. +declareOracle({ owns: [`src/proxy.ts`] }) + describe(`Mutation result detachment`, () => { // For any number of ordinary fields, one write changes only that field and // defines no property descriptors: a generated law over the field count. it(`copies ordinary fields without descriptor writes`, () => { + const parameters = oracleFixedSeed(20260914, { numRuns: 40 }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(fc.integer({ min: 1, max: 40 }), (count) => { const row = Object.fromEntries( @@ -59,13 +72,14 @@ describe(`Mutation result detachment`, () => { expect(row.field0).toBe(0) expect(writes).toBe(0) }), - { seed: 20260914, numRuns: 40 }, + parameters, ) }) it.each([`value`, `__proto__`, `constructor`, `toString`])( `rejects missing or prototype-changing %s data properties`, (key) => { + oracleRoute(`calibration`) expectOwnDataProperty(Object.fromEntries([[key, 1]]), key, 1) expect(() => expectOwnDataProperty({}, key, 1)).toThrow() const wrong = Object.fromEntries([[key, 1]]) @@ -81,6 +95,7 @@ describe(`Mutation result detachment`, () => { )( `preserves native own-property histories for $key with schema=$identitySchema`, async ({ key, identitySchema }) => { + oracleRoute(`case`) // Assignment to an existing data property must not invoke an inherited // setter, even when extraction also includes an unrelated object edit. for (const sibling of [false, true]) { @@ -175,6 +190,13 @@ describe(`Mutation result detachment`, () => { it.each([20260914, undefined])( `preserves JSON data keys on assignment seed=%s`, (seed) => { + const campaignParameters = { + ...(seed === undefined + ? oraclePropertyOptions(100, `proxy.json-data-keys`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(100) })), + examples: [[`__proto__`, 1, false]], + } + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property( fc.constantFrom(`__proto__`, `constructor`, `toString`, `ordinary`), @@ -201,11 +223,7 @@ describe(`Mutation result detachment`, () => { expect(original).toEqual({ payload: { before: true } }) }, ), - { - seed, - numRuns: oracleRuns(100), - examples: [[`__proto__`, 1, false]], - }, + campaignParameters, ) }, ) @@ -216,6 +234,7 @@ describe(`Mutation result detachment`, () => { )( `detaches a $kind draft backedge with the scalar edit $edit assignment`, async ({ kind, edit }) => { + oracleRoute(`case`) const original: CycleRow = { id: 1, count: 0 } const collection = storedRow(original) let settled: Promise | undefined @@ -240,6 +259,7 @@ describe(`Mutation result detachment`, () => { ) it.each(cycleKinds)(`accepts a native %s cyclic snapshot`, (kind) => { + oracleRoute(`case`) const row: CycleRow = { id: 1, count: 1 } row.link = cycleContainer(kind, row) expectCycleSnapshot(row, kind) @@ -253,6 +273,7 @@ describe(`Mutation result detachment`, () => { })), ), )(`rejects $fault in a $kind cyclic snapshot`, ({ kind, fault }) => { + oracleRoute(`calibration`) const row: CycleRow = { id: 1, count: 1 } row.link = cycleContainer(kind, row) if (fault === `old-value`) @@ -278,6 +299,7 @@ describe(`Mutation result detachment`, () => { // semantics; shared, resizable and detached buffers are outside this matrix. for (const kind of [`buffer`, `view`, `u8`, `i64`] as const) { it(`detaches assigned ${kind} without losing buffer bytes or view range`, () => { + oracleRoute(`case`) const bytes = Uint8Array.from({ length: 24 }, (_, index) => index) const value = kind === `buffer` diff --git a/packages/db/tests/proxy-detachment-contract.test.ts b/packages/db/tests/proxy-detachment-contract.test.ts index 11429067df..aef020aeeb 100644 --- a/packages/db/tests/proxy-detachment-contract.test.ts +++ b/packages/db/tests/proxy-detachment-contract.test.ts @@ -2,7 +2,7 @@ * Ordinary detachment regressions for draft values stored in a Collection. * The law and its independent model are owned by * proxy-detachment-contract-oracle.test.ts, which also runs the generated - * descriptor-write property. These tests pin literal expectations and do not + * descriptor-write property. These tests fix literal expectations and do not * compare against a model. */ diff --git a/packages/db/tests/proxy-iteration-contract-oracle.test.ts b/packages/db/tests/proxy-iteration-contract-oracle.test.ts index 7e9d98a8af..70f1822acf 100644 --- a/packages/db/tests/proxy-iteration-contract-oracle.test.ts +++ b/packages/db/tests/proxy-iteration-contract-oracle.test.ts @@ -3,6 +3,7 @@ import { createCollection } from '../src/collection/index.js' import { withChangeTracking } from '../src/proxy.js' import { declareOracle } from './oracle-declaration.js' import { expectMembers, protocols, visit } from './proxy-iteration-fixture.js' +import { oracleRoute } from './oracle-config.js' import type { Item, Protocol } from './proxy-iteration-fixture.js' /** @@ -49,7 +50,10 @@ function nativeWrittenValues | Set>( return values } +// What this oracle owns: the gap report judges the draft's Map, Set, and Array +// iteration in proxy.ts by this file's tests. declareOracle({ + owns: [`src/proxy.ts`], check: [expectNativeVisits], model: [nativeWrittenValues], }) @@ -102,6 +106,7 @@ describe(`Array draft iteration`, () => { it.each(edits)( `matches native visits and detached changes after %s`, (_edit, edit) => { + oracleRoute(`case`) const native = make() const expectedVisits = run(native.items, iterate, edit) let visits: Array = [] @@ -121,6 +126,7 @@ describe.each([`Map`, `Set`] as const)( it.each([`mutate`, `delete-readd`, `second-entry`] as const)( `matches native raw-object mutation after insertion: %s`, (operation) => { + oracleRoute(`case`) const run = (values: Map | Set) => { const item = { x: 1 } if (values instanceof Map) values.set(`a`, item) @@ -156,6 +162,7 @@ describe.each([`Map`, `Set`] as const)(`%s nested iteration laws`, (kind) => { it.each(protocols)( `%s matches native membership changes during iteration`, (protocol) => { + oracleRoute(`case`) const run = (values: Map | Set) => { const seen: Array = [] visit(values, protocol, (value) => { @@ -195,6 +202,7 @@ describe.each([`Map`, `Set`] as const)(`%s nested iteration laws`, (kind) => { it.each(protocols)( `Set %s observes clear and re-add after an edit just like a native iterator`, (protocol) => { + oracleRoute(`case`) const run = (values: Set) => { const seen: Array = [] visit(values, protocol, (value) => { @@ -226,6 +234,7 @@ it.each( )( `$kind $protocol writes match native values before and after persistence`, async ({ kind, protocol }) => { + oracleRoute(`case`) const make = () => kind === `Map` ? new Map([[`a`, { x: 1 }]]) : new Set([{ x: 1 }]) const expected = nativeWrittenValues(make(), protocol) diff --git a/packages/db/tests/proxy-native-methods-oracle.property.test.ts b/packages/db/tests/proxy-native-methods-oracle.property.test.ts index a1e03f72a8..510ffd674c 100644 --- a/packages/db/tests/proxy-native-methods-oracle.property.test.ts +++ b/packages/db/tests/proxy-native-methods-oracle.property.test.ts @@ -1,8 +1,11 @@ import { describe, it as vitestIt } from 'vitest' import { fc, test as fcTest } from '@fast-check/vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -68,16 +71,26 @@ const it = oracleReplayTest(vitestIt) // The model is native array and typed-array behavior. observeArray and // observeTyped record a call on either side, so they are not declared. -declareOracle({ check: [expectArrayCall, expectTypedCall] }) +// What this oracle owns: the gap report judges the built-in array and typed +// array method behavior of drafts in proxy.ts by this file's tests. +declareOracle({ + owns: [`src/proxy.ts`], + check: [expectArrayCall, expectTypedCall], +}) describe(`array methods behave like native arrays`, () => { - // The row of the original pinned table, for every method. + // The row of the original fixed table, for every method. it.each(arrayMethods)(`%s on a fixed row gives the native result`, (m) => { + oracleRoute(`case`) for (const k of [0, 1, 3]) expectArrayCall([{ x: 1 }, 2, [{ x: 3 }], { x: 0 }], m, k) }) it(`matches native arrays across generated rows (${FIXED_SEED})`, () => { + const parameters = oracleFixedSeed(FIXED_SEED, { + numRuns: oracleRuns(RUNS), + }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property( arraySpecArb, @@ -85,27 +98,37 @@ describe(`array methods behave like native arrays`, () => { fc.nat(4), expectArrayCall, ), - { numRuns: oracleRuns(RUNS), seed: FIXED_SEED }, + parameters, ) }) + const arraySpecCampaign = oraclePropertyOptions(RUNS, ARRAY_METHODS) oracleReplayTest(fcTest, ARRAY_METHODS).prop( [arraySpecArb, fc.constantFrom(...arrayMethods), fc.nat(4)], - oraclePropertyOptions(RUNS, ARRAY_METHODS), - )(`matches native arrays across generated rows (random)`, expectArrayCall) + arraySpecCampaign, + )(`matches native arrays across generated rows (random)`, (...args) => { + oracleRoute(oracleRouteOf(arraySpecCampaign)) + expectArrayCall(...args) + }) }) describe(`typed-array methods behave like native typed arrays`, () => { - // The row of the original pinned table, for every method. + // The row of the original fixed table, for every method. it.each(typedMethods)(`%s on a fixed row gives the native result`, (m) => { + oracleRoute(`case`) for (const write of [false, true]) expectTypedCall([3, 1, 2], m, 1, write) }) // Reversal swaps distinct signed-zero values. The native comparison checks // their final positions, including the detached change-set value. it(`reversing signed zeros preserves their native positions`, () => { + oracleRoute(`case`) expectTypedCall([-0, 0], `reverse`, 0, false) }) it(`matches native typed arrays across generated rows (${FIXED_SEED})`, () => { + const campaignParameters = oracleFixedSeed(FIXED_SEED, { + numRuns: oracleRuns(RUNS), + }) + oracleRoute(oracleRouteOf(campaignParameters)) fc.assert( fc.property( typedSpecArb, @@ -114,14 +137,15 @@ describe(`typed-array methods behave like native typed arrays`, () => { fc.boolean(), expectTypedCall, ), - { numRuns: oracleRuns(RUNS), seed: FIXED_SEED }, + campaignParameters, ) }) + const typedSpecCampaign = oraclePropertyOptions(RUNS, TYPED_METHODS) oracleReplayTest(fcTest, TYPED_METHODS).prop( [typedSpecArb, fc.constantFrom(...typedMethods), fc.nat(4), fc.boolean()], - oraclePropertyOptions(RUNS, TYPED_METHODS), - )( - `matches native typed arrays across generated rows (random)`, - expectTypedCall, - ) + typedSpecCampaign, + )(`matches native typed arrays across generated rows (random)`, (...args) => { + oracleRoute(oracleRouteOf(typedSpecCampaign)) + expectTypedCall(...args) + }) }) diff --git a/packages/db/tests/proxy-native-methods.test.ts b/packages/db/tests/proxy-native-methods.test.ts index 6db9f653ef..b94d2cb5e8 100644 --- a/packages/db/tests/proxy-native-methods.test.ts +++ b/packages/db/tests/proxy-native-methods.test.ts @@ -1,5 +1,5 @@ /** - * Ordinary coverage pins for native array and typed-array method tests: every + * Ordinary fixed examples for native array and typed-array method tests: every * method has call arguments, and the fixed campaign reaches each generator * shape. The native-method law is owned by proxy-native-methods-oracle.property.test.ts. */ diff --git a/packages/db/tests/proxy-oracle.test.ts b/packages/db/tests/proxy-oracle.test.ts index 4553862c1a..d2aef7a708 100644 --- a/packages/db/tests/proxy-oracle.test.ts +++ b/packages/db/tests/proxy-oracle.test.ts @@ -4,8 +4,11 @@ import { createCollection } from '../src/collection/index' import { createChangeProxy, withChangeTracking } from '../src/proxy' import { makeFrozenRow, makeStoredRow } from './proxy-native-rows' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config' import { declareOracle } from './oracle-declaration' @@ -21,7 +24,7 @@ import type { FrozenRow, StoredRow } from './proxy-native-rows' * descriptors, keys, iteration, cycles, aliases, and revert-to-original rules. * The input object must remain unchanged. * - * Pinned examples of individual language operations live in proxy.test.ts. + * Fixed examples of individual language operations live in proxy.test.ts. * The array callback oracle is differential: it runs the same callback history on a plain native row and * on a draft, then compares callback results, visit order, reconstructed rows, * change records, peers, and the untouched baseline. Generated two-step @@ -182,13 +185,18 @@ function writeThroughAccessPath( return row } +// What this oracle owns: the gap report judges the draft's array callback +// methods and the stored-function and defineProperty behavior of drafts in +// proxy.ts by this file's tests. declareOracle({ + owns: [`src/proxy.ts`], check: [assertArrayCallbacks], model: [runArrayCallback, writeThroughAccessPath], }) describe(`native array callback oracle`, () => { it(`keeps the shared baseline independent of every captured actual cut`, () => { + oracleRoute(`fixed history`) const cuts = observeArrayCallbacks( [1, 2], [ @@ -208,6 +216,7 @@ describe(`native array callback oracle`, () => { it.each(accessPaths)( `preserves native writes through the %s access path`, async (path) => { + oracleRoute(`case`) for (const delta of [0, 3, -2]) { const make = () => ({ id: 1, items: [{ value: 1 }, { value: 2 }] }) const run = (row: ReturnType) => @@ -255,6 +264,7 @@ describe(`native array callback oracle`, () => { it.each(callbackMethods)( `matches native %s reads, writes and reverts`, (method) => { + oracleRoute(`case`) for (const target of [0, 1, 2]) { for (const deltas of [ [0, 0], @@ -299,6 +309,10 @@ describe(`native array callback oracle`, () => { [`zero-sign-write`], { minimum: 5 }, ) + const parameters = options as fc.Parameters< + [{ values: Array; steps: unknown }] + > + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(callbackHistory, ({ values, steps }) => { reach.beginHistory() @@ -318,14 +332,14 @@ describe(`native array callback oracle`, () => { } assertArrayCallbacks(cuts) }), - options as fc.Parameters<[{ values: Array; steps: unknown }]>, + parameters, ) reach.assertReached() } oracleReplayTest(it)( `matches native two-step callback histories (2026103)`, () => { - callbackCampaign({ seed: 2026103, numRuns: oracleRuns(200) }) + callbackCampaign(oracleFixedSeed(2026103, { numRuns: oracleRuns(200) })) }, ) oracleReplayTest(it, `proxy.callback-history`)( @@ -338,6 +352,7 @@ describe(`native array callback oracle`, () => { it.each([`lost-write`, `extra-visit`, `wrong-peer`] as const)( `rejects a captured %s independently of the native authority`, (fault) => { + oracleRoute(`calibration`) const cuts = observeArrayCallbacks( [4, -2, 7], [{ method: `forEach`, target: 1, delta: 3 }], @@ -505,6 +520,7 @@ describe(`stored functions behave like native values`, () => { declareOracle({ check: [expectNativeProbeResult] }) it.each(probes)(`%s gives the native result`, (_name, probe) => { + oracleRoute(`case`) expectNativeProbeResult(probe) }) }) @@ -585,6 +601,7 @@ describe(`defineProperty behaves like on a native row`, () => { declareOracle({ check: [expectDefinitionMatchesNative] }) it.each(definitions)(`%s`, (_name, define) => { + oracleRoute(`case`) expectDefinitionMatchesNative(define) }) }) @@ -638,6 +655,7 @@ describe(`frozen and sealed drafts keep nested writes`, () => { declareOracle({ check: [expectNativeRowAfter] }) it.each(histories)(`%s gives the native row`, (_name, run) => { + oracleRoute(`case`) expectNativeRowAfter(run) }) diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index e54dc246cd..7ddf4a193f 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it as vitestIt } from 'vitest' import { fc, test as fcTest } from '@fast-check/vitest' import { createChangeProxy } from '../src/proxy' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config' import { declareOracle } from './oracle-declaration' @@ -851,6 +854,18 @@ const SIGNED_ZERO = `proxy.revert-signed-zero` const it = oracleReplayTest(vitestIt) +// A random campaign registers the route of its parameters inside each +// generated predicate. +const randomPredicate = + >( + parameters: Parameters[0], + check: (...args: TArgs) => void, + ) => + (...args: TArgs): void => { + oracleRoute(oracleRouteOf(parameters)) + check(...args) + } + describe(`draft revert oracle`, () => { // These six fixed histories reconstruct the scalar false-green witness and // its nested/Float64 neighbors in both directions. They use the generated @@ -869,18 +884,20 @@ describe(`draft revert oracle`, () => { fixedHistories: zeroExamples.length, }) let histories = 0 + const parameters = { + ...oraclePropertyOptions(100, SIGNED_ZERO, { + examples: zeroExamples, + }), + ...(campaign === `fixed` ? oracleFixedSeed(FIXED_SEED) : {}), + } + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(signedZeroArb, (input) => { reach.beginHistory({ fixed: histories++ < zeroExamples.length }) expectHistory(signedZeroHistory(input)) reach.reach(input.path) }), - { - ...oraclePropertyOptions(100, SIGNED_ZERO, { - examples: zeroExamples, - }), - ...(campaign === `fixed` ? { seed: FIXED_SEED } : {}), - }, + parameters, ) reach.assertReached() process.stdout.write( @@ -890,66 +907,75 @@ describe(`draft revert oracle`, () => { ) it(`matches the model across generated write histories (${FIXED_SEED})`, () => { - fc.assert(fc.property(historyArb, expectHistory), { + const campaignParameters = oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(400), - seed: FIXED_SEED, }) + oracleRoute(oracleRouteOf(campaignParameters)) + fc.assert(fc.property(historyArb, expectHistory), campaignParameters) }) - oracleReplayTest(fcTest, WRITE_HISTORY).prop( - [historyArb], - oraclePropertyOptions(400, WRITE_HISTORY), - )( + const historyCampaign = oraclePropertyOptions(400, WRITE_HISTORY) + oracleReplayTest(fcTest, WRITE_HISTORY).prop([historyArb], historyCampaign)( `matches the model across generated write histories (random)`, - expectHistory, + randomPredicate(historyCampaign, expectHistory), ) it(`matches the model across generated nested round trips (${FIXED_SEED})`, () => { - fc.assert(fc.property(nestedRoundTripArb, expectHistory), { + const runParameters = oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(200), - seed: FIXED_SEED, }) + oracleRoute(oracleRouteOf(runParameters)) + fc.assert(fc.property(nestedRoundTripArb, expectHistory), runParameters) }) + const nestedRoundTripCampaign = oraclePropertyOptions(200, NESTED_ROUND_TRIPS) oracleReplayTest(fcTest, NESTED_ROUND_TRIPS).prop( [nestedRoundTripArb], - oraclePropertyOptions(200, NESTED_ROUND_TRIPS), + nestedRoundTripCampaign, )( `matches the model across generated nested round trips (random)`, - expectHistory, + randomPredicate(nestedRoundTripCampaign, expectHistory), ) it(`matches the model across generated URL and keyless writes (${FIXED_SEED})`, () => { - fc.assert(fc.property(classWriteArb, expectHistory), { + const parameters = oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(200), - seed: FIXED_SEED, }) + oracleRoute(oracleRouteOf(parameters)) + fc.assert(fc.property(classWriteArb, expectHistory), parameters) }) + const classWriteCampaign = oraclePropertyOptions(200, CLASS_WRITES) oracleReplayTest(fcTest, CLASS_WRITES).prop( [classWriteArb], - oraclePropertyOptions(200, CLASS_WRITES), + classWriteCampaign, )( `matches the model across generated URL and keyless writes (random)`, - expectHistory, + randomPredicate(classWriteCampaign, expectHistory), ) it(`matches the model across generated partial reverts (${FIXED_SEED})`, () => { - fc.assert(fc.property(partialRevertArb, expectHistory), { + const parameters = oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(200), - seed: FIXED_SEED, }) + oracleRoute(oracleRouteOf(parameters)) + fc.assert(fc.property(partialRevertArb, expectHistory), parameters) }) + const partialRevertCampaign = oraclePropertyOptions(200, PARTIAL_REVERTS) oracleReplayTest(fcTest, PARTIAL_REVERTS).prop( [partialRevertArb], - oraclePropertyOptions(200, PARTIAL_REVERTS), + partialRevertCampaign, )( `matches the model across generated partial reverts (random)`, - expectHistory, + randomPredicate(partialRevertCampaign, expectHistory), ) // Positive execution witness: the fixed campaign reaches each operation, a // partial revert (some change survives one revert), a full revert, and a // change made only under a nested symbol key. it(`reaches every operation and both revert outcomes in the fixed campaign`, () => { - const sample = fc.sample(historyArb, { seed: FIXED_SEED, numRuns: 400 }) + oracleRoute(`fixed seed`) + const sample = fc.sample( + historyArb, + oracleFixedSeed(FIXED_SEED, { numRuns: 400 }), + ) const reached = new Set() let partial = 0 let full = 0 @@ -1019,9 +1045,13 @@ describe(`draft revert oracle`, () => { // Writes that a keyless comparison would call equal: another URL, or a // Secret over another Secret or over an object without keys. it(`reaches writes over URLs and keyless objects in the fixed campaign`, () => { + oracleRoute(`fixed seed`) let urlWrites = 0 let keylessWrites = 0 - const sample = fc.sample(classWriteArb, { seed: FIXED_SEED, numRuns: 200 }) + const sample = fc.sample( + classWriteArb, + oracleFixedSeed(FIXED_SEED, { numRuns: 200 }), + ) for (const { original, ops } of sample) { let state: Root = { ...original } for (const generated of ops) { @@ -1043,7 +1073,7 @@ describe(`draft revert oracle`, () => { expect(keylessWrites).toBeGreaterThanOrEqual(10) }) - // Pinned witnesses. + // Fixed histories. it.each<[string, History]>([ [ `reverting one of two changed fields keeps the other change`, @@ -1301,7 +1331,10 @@ describe(`draft revert oracle`, () => { ], }, ], - ])(`%s`, (_name, history) => expectHistory(history)) + ])(`%s`, (_name, history) => { + oracleRoute(`fixed history`) + return expectHistory(history) + }) }) // --------------------------------------------------------------------------- @@ -1507,7 +1540,10 @@ function expectNativeHistory(history: NativeHistory): void { expectNativeResult(history, driveNative(history)) } +// What this oracle owns: the gap report judges the draft's revert and change +// report, and its native mutator writes, in proxy.ts by this file's tests. declareOracle({ + owns: [`src/proxy.ts`], check: [expectHistory, expectNativeResult], model: [numberToken, step, expectedChanges, nativeModel], grammar: [signedZeroArb, signedZeroHistory, nativeHistoryArb], @@ -1515,6 +1551,7 @@ declareOracle({ describe(`draft revert oracle: native mutators mixed with assignments`, () => { it(`rejects an unwritten field in the change report`, () => { + oracleRoute(`calibration`) const histories: Array = [ { original: { x: 0, f: { arr: [1], g: { a: 0 } } }, @@ -1538,26 +1575,29 @@ describe(`draft revert oracle: native mutators mixed with assignments`, () => { }) it(`matches the model across generated native histories (${FIXED_SEED})`, () => { - fc.assert(fc.property(nativeHistoryArb, expectNativeHistory), { + const parameters = oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(400), - seed: FIXED_SEED, }) + oracleRoute(oracleRouteOf(parameters)) + fc.assert(fc.property(nativeHistoryArb, expectNativeHistory), parameters) }) + const nativeHistoryCampaign = oraclePropertyOptions(400, NATIVE_HISTORIES) oracleReplayTest(fcTest, NATIVE_HISTORIES).prop( [nativeHistoryArb], - oraclePropertyOptions(400, NATIVE_HISTORIES), + nativeHistoryCampaign, )( `matches the model across generated native histories (random)`, - expectNativeHistory, + randomPredicate(nativeHistoryCampaign, expectNativeHistory), ) // Positive execution witness: the fixed campaign reaches a native write // that an assignment then replaces, and a mutator through a detached handle. it(`reaches replaced native writes and detached handles in the fixed campaign`, () => { - const sample = fc.sample(nativeHistoryArb, { - seed: FIXED_SEED, - numRuns: 400, - }) + oracleRoute(`fixed seed`) + const sample = fc.sample( + nativeHistoryArb, + oracleFixedSeed(FIXED_SEED, { numRuns: 400 }), + ) const replaced = sample.filter((h) => { const first = h.ops.findIndex((o) => [`push`, `pop`, `reverse`].includes(o.op), @@ -1623,5 +1663,8 @@ describe(`draft revert oracle: native mutators mixed with assignments`, () => { ], }, ], - ])(`%s`, (_name, history) => expectNativeHistory(history)) + ])(`%s`, (_name, history) => { + oracleRoute(`fixed history`) + return expectNativeHistory(history) + }) }) diff --git a/packages/db/tests/query/aggregate-select-oracle.property.test.ts b/packages/db/tests/query/aggregate-select-oracle.property.test.ts index da9c76d130..1715080aa5 100644 --- a/packages/db/tests/query/aggregate-select-oracle.property.test.ts +++ b/packages/db/tests/query/aggregate-select-oracle.property.test.ts @@ -37,9 +37,12 @@ import { } from '../../src/errors.js' import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleRandomParameters, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { createOracleReach } from '../oracle-reach.js' @@ -1785,6 +1788,15 @@ async function runCampaign( const generatedExamples = fc .sample(requiredHistories, { seed: parameters.seed, numRuns: 1 })[0]! .map((entry) => [entry] as [History]) + const campaignParameters = { + ...parameters, + examples: [...parameters.examples, ...generatedExamples], + numRuns: + parameters.path === undefined + ? parameters.numRuns + generatedExamples.length + : parameters.numRuns, + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(history, async (entry) => { const fixed = structuralInputs.has(entry) @@ -1792,14 +1804,7 @@ async function runCampaign( reach.beginHistory({ fixed }) await runHistory(entry) }), - { - ...parameters, - examples: [...parameters.examples, ...generatedExamples], - numRuns: - parameters.path === undefined - ? parameters.numRuns + generatedExamples.length - : parameters.numRuns, - }, + campaignParameters, ) structuralReach.assertReached() generatedReach.assertReached() @@ -1825,6 +1830,10 @@ async function runCampaign( // `modelGroup` and `modelError`. Generated histories, structural examples, and // existing model replays use this same check. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [runHistory], model: [modelGroup, modelError], grammar: [history, requiredHistories], @@ -1835,14 +1844,19 @@ describe(`aggregate select shapes`, () => { `match the model across generated shapes (fixed campaign)`, async () => { // Keep the literal budget/seed binding visible to the run-budget audit. - const options = { numRuns: oracleRuns(160), seed: 2094 } + // runCampaign registers the route of the parameters, which declare the + // seed fixed. + const options = oracleFixedSeed(2094, { numRuns: oracleRuns(160) }) await runCampaign( `fixed`, - oracleRandomParameters( - options.numRuns, + oracleFixedSeed( options.seed, - undefined, - examples, + oracleRandomParameters( + options.numRuns, + options.seed, + undefined, + examples, + ), ), ) }, @@ -1858,9 +1872,10 @@ describe(`aggregate select shapes`, () => { }, ) - // Pinned replay of a review counterexample: a grouped select value that + // Fixed regression of a review counterexample: a grouped select value that // holds the group key inside a function must read the group's key. oracleReplayTest(it)(`reads the group key inside a function`, async () => { + oracleRoute(`fixed history`) await runHistory({ top: { fields: { f0: { k: `add`, a: { k: `lit`, n: 0 }, b: { k: `key` } } }, @@ -1870,9 +1885,10 @@ describe(`aggregate select shapes`, () => { }) }) - // Pinned replay: a published row keeps the select's field order, also + // Fixed regression: a published row keeps the select's field order, also // when a group key comes before a plain aggregate. oracleReplayTest(it)(`keeps the select's field order`, async () => { + oracleRoute(`fixed history`) await runHistory({ top: { fields: { @@ -1885,11 +1901,12 @@ describe(`aggregate select shapes`, () => { }) }) - // Pinned replay of a generated counterexample: an aggregate query without + // Fixed regression of a generated counterexample: an aggregate query without // groupBy whose select wraps its only aggregate still accepts HAVING. oracleReplayTest(it)( `accepts HAVING beside a wrapped aggregate without groupBy`, async () => { + oracleRoute(`fixed history`) await runHistory({ top: { fields: { @@ -1912,11 +1929,12 @@ describe(`aggregate select shapes`, () => { }, ) - // Pinned replay: a parent field stays legal when the include's source + // Fixed regression: a parent field stays legal when the include's source // shadows the parent alias. oracleReplayTest(it)( `reads a parent field under a shadowing child alias`, async () => { + oracleRoute(`fixed history`) await runHistory({ top: { fields: { f0: { k: `agg`, fn: `count` } } }, nested: { diff --git a/packages/db/tests/query/aggregate-select.test.ts b/packages/db/tests/query/aggregate-select.test.ts index 2ef5555602..cdba5b9925 100644 --- a/packages/db/tests/query/aggregate-select.test.ts +++ b/packages/db/tests/query/aggregate-select.test.ts @@ -15,7 +15,7 @@ import { import { mockSyncCollectionOptions } from '../utils.js' describe(`aggregate select shapes`, () => { - // Pinned witness: a group key that holds a literal object or NaN matches a + // Fixed history: a group key that holds a literal object or NaN matches a // select value that builds the same literal again. it(`matches group keys holding array, Date and NaN literals`, async () => { const source = createCollection( diff --git a/packages/db/tests/query/async-hold-outcomes.test.ts b/packages/db/tests/query/async-hold-outcomes.test.ts index 3590483138..c348082f7c 100644 --- a/packages/db/tests/query/async-hold-outcomes.test.ts +++ b/packages/db/tests/query/async-hold-outcomes.test.ts @@ -12,7 +12,7 @@ import type { LoadSubsetOptions } from '../../src/types.js' * Initial-query readiness and Effect `skipInitial` suppression wait on held * acquisitions: a live query's lazy demand and an Effect's initial loads. * Model D in `includes-temporal-oracle.test.ts` owns the settle, abort - * (rekey), and truncate-replacement outcomes. This file pins the remaining + * (rekey), and truncate-replacement outcomes. This file covers the remaining * outcomes for one hold at a time: * * - a real load failure disposes a `skipInitial` Effect and delivers nothing; diff --git a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts index ed768a469e..f10d3a96a8 100644 --- a/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts +++ b/packages/db/tests/query/bucket-facade-rollback-oracle.property.test.ts @@ -1,7 +1,13 @@ import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { stripVirtualProps } from '../utils.js' -import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { BUCKETS, @@ -109,8 +115,8 @@ import type { * positive is the only event a failed flush may publish. * The work counter wraps the iteration, `get` and `has` methods of * each facade's stored rows during the flush and records which facades the - * flush read. A pinned case covers nested facades across two edges: the child - * edge commits, then the parent edge throws. Pinned cases also cover a + * flush read. A fixed history covers nested facades across two edges: the child + * edge commits, then the parent edge throws. Fixed histories also cover a * facade subscriber that throws during publication, which must not hold back * another facade's events, and a restore that throws, which must still close * every facade's deferred events. @@ -774,12 +780,16 @@ const step = fc.record({ settleLocal: fc.integer({ min: 0, max: 5 }).map((n) => n === 0), }) const history = fc.array(step, { minLength: 1, maxLength: 8 }) -// The parts this file declares. runHistory runs one generated or pinned +// The parts this file declares. runHistory runs one generated or fixed // history through the real adapter and compares it with the model after each // flush; checkEvents is its exactly-once publication comparison. The model // predicts the rows that each flush owes, which events it may name, and which // layout revision it owes. declareOracle({ + owns: [ + 'src/query/live/bucket-facade-adapter.ts', + 'src/transactions.ts#createTransaction', + ], check: [runHistory, checkEvents], model: [applyOps, layoutExpectation, shownLayoutExpectation, touchedIds], }) @@ -787,23 +797,22 @@ declareOracle({ describe(`bucket facade rollback`, () => { it(`restores written facades and reads no others (fixed campaign)`, async () => { // A repeatable baseline. Seed 2081 is arbitrary. - await fc.assert(fc.asyncProperty(history, runHistory), { - numRuns: oracleRuns(150), - seed: 2081, - }) + const parameters = oracleFixedSeed(2081, { numRuns: oracleRuns(150) }) + oracleRoute(oracleRouteOf(parameters)) + await fc.assert(fc.asyncProperty(history, runHistory), parameters) }) it(`restores written facades and reads no others (random or replayed)`, async () => { - await fc.assert( - fc.asyncProperty(history, runHistory), - oraclePropertyOptions(150, PROPERTY), - ) + const campaignParameters = oraclePropertyOptions(150, PROPERTY) + oracleRoute(oracleRouteOf(campaignParameters)) + await fc.assert(fc.asyncProperty(history, runHistory), campaignParameters) }) - // Pinned: a flush that reorders a shown row throws. The retried flush must + // Fixed history: a flush that reorders a shown row throws. The retried flush must // still publish the layout change, so the failed flush must not leave its // new order behind as the facade's current order. it(`publishes a reorder on retry after a failed reordering flush`, async () => { + oracleRoute(`fixed history`) await runHistory([ { choices: [ @@ -827,10 +836,11 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned: a reorder rolled back after `prepare()`, then sent again. The + // Fixed history: a reorder rolled back after `prepare()`, then sent again. The // rollback must restore the facade's current order, or the second flush // sees no order change and owes a layout revision it does not publish. it(`publishes a reorder sent again after a rolled-back reorder`, async () => { + oracleRoute(`fixed history`) await runHistory([ { choices: [ @@ -854,10 +864,11 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned: a failed root commit, then a retry that carries only changes to + // Fixed history: a failed root commit, then a retry that carries only changes to // other buckets, as a parent-only write would. The retry must still publish // the facade change that was pending when the root commit failed. it(`publishes a pending facade change after a failed root commit`, async () => { + oracleRoute(`fixed history`) await runHistory([ { choices: [ @@ -884,6 +895,7 @@ describe(`bucket facade rollback`, () => { // row from a pending user transaction, or a held sync commit behind a // persisting one. Retiring the bucket must not fail the flush. it(`retires a bucket whose facade shows an optimistic row`, async () => { + oracleRoute(`fixed history`) await runHistory([ { choices: [ @@ -902,9 +914,10 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned: a failed flush that writes two existing buckets and retires a + // Fixed history: a failed flush that writes two existing buckets and retires a // third restores all three, then the retried flush applies everything. it(`restores two written buckets and a retired bucket, then retries`, async () => { + oracleRoute(`fixed history`) await runHistory([ { choices: [ @@ -935,9 +948,10 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned replay of a generated failure: an update to a row that an earlier + // Fixed regression of a generated failure: an update to a row that an earlier // flush wrote behind a still-persisting transaction keeps the row. it(`updates a row that a persisting transaction still holds`, async () => { + oracleRoute(`fixed history`) await runHistory([ { choices: [{ kind: 0, bucket: 0, id: 0, v: 0, rank: 0 }], @@ -959,10 +973,11 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned replay of a CI failure: a rollback restores a row that an earlier + // Fixed regression of a CI failure: a rollback restores a row that an earlier // flush wrote behind a still-persisting transaction. The restore must write // it as an update, because the held insert lands first. it(`restores a held row after a rolled-back update`, async () => { + oracleRoute(`fixed history`) await runHistory([ { choices: [{ kind: 0, bucket: 3, id: 0, v: 0, rank: 0 }], @@ -987,10 +1002,11 @@ describe(`bucket facade rollback`, () => { ]) }) - // Pinned replay of a generated failure: an optimistic row shares a graph + // Fixed regression of a generated failure: an optimistic row shares a graph // id, is rolled back, and then a flush fails while writing the facade. The // failed flush publishes nothing. it(`publishes nothing from a failed flush after a shared optimistic row leaves`, async () => { + oracleRoute(`fixed history`) const plain = { throwPick: 0, local: 0, diff --git a/packages/db/tests/query/bucket-facade-rollback.test.ts b/packages/db/tests/query/bucket-facade-rollback.test.ts index 9e33ef5b94..ce2327b617 100644 --- a/packages/db/tests/query/bucket-facade-rollback.test.ts +++ b/packages/db/tests/query/bucket-facade-rollback.test.ts @@ -29,7 +29,7 @@ function kidsOf(row: unknown): unknown { } describe(`bucket facade rollback`, () => { - // Pinned: a list of 50 buckets with 3 rows each, where one insert touches + // Fixed history: a list of 50 buckets with 3 rows each, where one insert touches // one bucket. The flush must read that bucket's rows and no others. it(`reads only the written facade in a wide list`, async () => { const driver = new Driver() @@ -62,7 +62,7 @@ describe(`bucket facade rollback`, () => { () => driver.cleanup(), ) }) - // Pinned: nested facades. A parent facade's row refers to a child facade. + // Fixed history: nested facades. A parent facade's row refers to a child facade. // The child edge commits first, then the parent edge's commit throws. The // failed flush must restore the child's rows, keep the parent's reference // to the old child facade, and drop the child facade it created. The retry diff --git a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts index 9a018c1e77..95191bd943 100644 --- a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts @@ -5,7 +5,10 @@ import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { and, createLiveQueryCollection, eq } from '../../src/query/index.js' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../oracle-config.js' @@ -374,6 +377,7 @@ describe.each([`off`, `eager`] as const)( it.each(equalityJoinCases)( `$label joins agree with equality predicates`, async ({ label, createValues }) => { + oracleRoute(`case`) const { left: leftValue, right: rightValue, other } = createValues() const left = createEqualitySource( `equality-left-${label}`, @@ -459,6 +463,7 @@ const history: ReadonlyArray = [ // amounts -5..5, and lengths 0..15. It excludes simultaneous duplicate child // keys, which a keyed Collection cannot represent, and unmatched parent IDs. it(`reconciles cold join acquisition, deletion, restoration and route moves`, async () => { + oracleRoute(`fixed history`) const batches = await runColdJoin(history) expect( batches.flat().filter((change) => change.type === `delete`).length, @@ -486,6 +491,11 @@ it.each(coldJoinSeeds)(`checks cold join histories, seed=%s`, async (seed) => { }) .map((row) => ({ type: `put` as const, row })), ) + const parameters = + seed === undefined + ? oraclePropertyOptions(50, coldJoinProperty) + : oracleFixedSeed(seed, { numRuns: oracleRuns(50) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array(step, { minLength: 0, maxLength: 15 }), @@ -493,14 +503,13 @@ it.each(coldJoinSeeds)(`checks cold join histories, seed=%s`, async (seed) => { await runColdJoin([...history, ...steps]) }, ), - seed === undefined - ? oraclePropertyOptions(50, coldJoinProperty) - : { seed, numRuns: oracleRuns(50) }, + parameters, ) }) it.each([`hide-acquisition`, `drop-delete`, `wrong-result`] as const)( `rejects %s through the real cold join trace checker`, async (mutant) => { + oracleRoute(`calibration`) await runColdJoin(history) await expect(runColdJoin(history, mutant)).rejects.toMatchObject({ name: `TraceAssertionError`, @@ -834,12 +843,17 @@ async function runCompoundHistory( * the scenario it runs, so the caller supplies inputs, not expected rows. */ declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [runColdJoin, runCompoundHistory, expectEqualityJoinMatchesPredicate], model: [expectedColdJoinRows, compoundPairs], }) // All atom classes occur in the initial matrix. Partial matches differ only -// in the last term; nulls occur in both tuple positions. The pinned scenario +// in the last term; nulls occur in both tuple positions. The fixed scenario // leaves, restores, deletes and reinserts a match on each side. const compoundBoundaryRows = compoundAtoms().map((_, atom) => ({ id: atom, @@ -875,6 +889,7 @@ describe(`compound join relational oracle`, () => { for (const autoIndex of [`off`, `eager`] as const) { for (const atomFirst of [false, true]) { it(`loads the matching compound tuple: index=${autoIndex}, atomFirst=${atomFirst}`, async () => { + oracleRoute(`case`) await runCompoundHistory({ ...compoundWitness, left: [{ id: 1, atom: 10, b: 7, c: 9 }], @@ -898,6 +913,7 @@ describe(`compound join relational oracle`, () => { for (const atom of [0, 21]) { for (const b of [null, 0]) { it(`checks nullish and satisfiable tuples: index=${autoIndex}, atomFirst=${atomFirst}, atom=${atom}, b=${b}`, async () => { + oracleRoute(`case`) await runCompoundHistory({ ...compoundWitness, left: [{ id: 0, atom, b, c: 0 }], @@ -921,6 +937,7 @@ describe(`compound join relational oracle`, () => { { name: `Date and ISO string`, leftAtom: 2, rightAtom: 3 }, { name: `bigint`, leftAtom: 8, rightAtom: 8 }, ])(`preserves compound $name equality`, async ({ leftAtom, rightAtom }) => { + oracleRoute(`case`) await runCompoundHistory({ ...compoundWitness, atomFirst: false, @@ -932,6 +949,7 @@ describe(`compound join relational oracle`, () => { for (const join of [`inner`, `left`, `right`, `full`] as const) { for (const cold of [false, true]) { it(`preserves all equality classes through ${join} histories, cold=${cold}`, async () => { + oracleRoute(`case`) await runCompoundHistory({ ...compoundWitness, join, cold }) }) } @@ -939,6 +957,7 @@ describe(`compound join relational oracle`, () => { it.each([`off`, `eager`] as const)( `rejects a hidden acquisition for a satisfiable tuple, index=%s`, async (autoIndex) => { + oracleRoute(`calibration`) await expect( runCompoundHistory( { @@ -970,6 +989,11 @@ describe(`compound join relational oracle`, () => { b: fc.constantFrom(0, 1, null), c: fc.integer({ min: 0, max: 1 }), }) + const campaignParameters = + seed === undefined + ? oraclePropertyOptions(60, property) + : oracleFixedSeed(seed, { numRuns: oracleRuns(60) }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -1004,9 +1028,7 @@ describe(`compound join relational oracle`, () => { }), (scenario) => runCompoundHistory(scenario), ), - seed === undefined - ? oraclePropertyOptions(60, property) - : { seed, numRuns: oracleRuns(60) }, + campaignParameters, ) }, ) diff --git a/packages/db/tests/query/compiler/evaluators-oracle.test.ts b/packages/db/tests/query/compiler/evaluators-oracle.test.ts index 88b2312d1a..ed9da08458 100644 --- a/packages/db/tests/query/compiler/evaluators-oracle.test.ts +++ b/packages/db/tests/query/compiler/evaluators-oracle.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest' import { compileExpression } from '../../../src/query/compiler/evaluators.js' import { Func, Value } from '../../../src/query/ir.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../oracle-config.js' import { declareOracle } from '../../oracle-declaration.js' @@ -20,8 +23,8 @@ import { declareOracle } from '../../oracle-declaration.js' * * History grammar: each case contains a value, a pattern, and a case-fold flag. * The small alphabet concentrates on literal/wildcard precedence, `_`, case, - * newlines, and non-ASCII code units. Every generated string is legal. Pinned - * witnesses reconstruct the reported `%` collision; removing `%` removes that + * newlines, and non-ASCII code units. Every generated string is legal. Fixed + * histories reconstruct the reported `%` collision; removing `%` removes that * distinction, while empty and literal-only cases exercise its boundaries. * * Production driver: compile a public `like` or `ilike` expression and invoke @@ -59,18 +62,17 @@ const likeCalibrationCases: Array = [ // The fixed campaign stands aside during a path replay. The random campaign uses // the seed of a replay of this property, when TANSTACK_DB_ORACLE_SEED supplies // one. This generator has no shrink path, so a path replay only reseeds it. +// Each campaign's parameters carry its seed and give the test its route. const likeOracleCampaigns = [ { name: `fixed`, test: oracleReplayTest(it), - seed: FIXED_LIKE_ORACLE_SEED, + parameters: oracleFixedSeed(FIXED_LIKE_ORACLE_SEED), }, { name: `random`, test: oracleReplayTest(it, likeProperty), - seed: - oraclePropertyOptions(10_000, likeProperty).seed ?? - Math.floor(Math.random() * 0x1_0000_0000), + parameters: oraclePropertyOptions(10_000, likeProperty), }, ] @@ -164,6 +166,7 @@ function findLikeMismatch( } declareOracle({ + owns: ['src/query/compiler/evaluators.ts#compileExpression'], check: [findLikeMismatch], model: [referenceLike], grammar: [seededLikeCases], @@ -174,6 +177,7 @@ describe(`evaluators`, () => { describe(`function compilation`, () => { describe(`like/ilike functions`, () => { it(`rejects the literal-before-wildcard wrong result`, () => { + oracleRoute(`calibration`) const witness: LikeCase = { value: `100% done`, pattern: `100%done`, @@ -194,7 +198,10 @@ describe(`evaluators`, () => { campaign.test( `matches the LIKE law in the ${campaign.name} campaign`, () => { - const { seed } = campaign + oracleRoute(oracleRouteOf(campaign.parameters)) + const seed = + campaign.parameters.seed ?? + Math.floor(Math.random() * 0x1_0000_0000) let mismatchCount = 0 const mismatchSamples: Array = [] const reach = { diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index ecf981355f..d878668429 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -1110,7 +1110,7 @@ describe(`evaluators`, () => { it(`equivalent forms compare equal but eq returns false`, () => { // PT60M and PT1H represent the same duration. .compare() ranks them // as equal, but .equals() (structural, field-by-field) does not, so - // eq returns false. This pins the asymmetry — and verifies we + // eq returns false. This records the asymmetry — and verifies we // dispatch to .compare() rather than lex-comparing toString output, // which would say "PT1H" < "PT60M". const sixtyMinutes = Temporal.Duration.from(`PT60M`) diff --git a/packages/db/tests/query/compiler/group-by-pipeline-oracle.test.ts b/packages/db/tests/query/compiler/group-by-pipeline-oracle.test.ts index 27ac136de2..af10c0d3dd 100644 --- a/packages/db/tests/query/compiler/group-by-pipeline-oracle.test.ts +++ b/packages/db/tests/query/compiler/group-by-pipeline-oracle.test.ts @@ -5,6 +5,7 @@ import { processGroupBy } from '../../../src/query/compiler/group-by.js' import { grouping } from '../../../src/query/compiler/grouping.js' import { createValueIdentity } from '../../../src/query/equality-value-identity.js' import { Aggregate, Func, PropRef, Value } from '../../../src/query/ir.js' +import { oracleRoute } from '../../oracle-config.js' import { declareOracle } from '../../oracle-declaration.js' import { createOracleReach } from '../../oracle-reach.js' import type { Select } from '../../../src/query/ir.js' @@ -109,6 +110,7 @@ function compareGroupRows( } declareOracle({ + owns: [`src/query/compiler/group-by.ts#processGroupBy`], model: [expectedGroupRows], check: [compareGroupRows], grammar: [cases, snapshots], @@ -118,6 +120,7 @@ describe(`group-by component batch refinement`, () => { test.each(cases)( `recomputes keyed selections: grouped=$grouped, select=$selection, having=$having`, (scenario) => { + oracleRoute(`case`) const { grouped, selection, having } = scenario const graph = new D2() const input = graph.newInput() @@ -224,6 +227,7 @@ describe(`group-by component batch refinement`, () => { `wrong value`, `duplicate weight`, ] as const)(`rejects a batch observation with %s`, (fault) => { + oracleRoute(`calibration`) const expected = expectedGroupRows(initial, { grouped: true, selection: `plain`, diff --git a/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts b/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts index b0e4a144c6..bd892a41ef 100644 --- a/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts +++ b/packages/db/tests/query/compiler/in-evaluator-oracle.property.test.ts @@ -3,7 +3,13 @@ import fc from 'fast-check' import { Temporal } from 'temporal-polyfill' import { compileExpression } from '../../../src/query/compiler/evaluators.js' import { Func, PropRef, Value } from '../../../src/query/ir.js' -import { oraclePropertyOptions, oracleRuns } from '../../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../oracle-config.js' import { declareOracle } from '../../oracle-declaration.js' /** @@ -223,7 +229,11 @@ function describeCase(testCase: InCase): string { return `value=${show(testCase.value)} list=[${testCase.list.map(show).join(`, `)}] listFromRow=${testCase.listFromRow}` } -declareOracle({ check: [check], model: [referenceIn] }) +declareOracle({ + owns: ['src/query/compiler/evaluators.ts#compileExpression'], + check: [check], + model: [referenceIn], +}) describe(`in evaluator`, () => { const property = fc.property(inCase, (testCase) => @@ -231,11 +241,15 @@ describe(`in evaluator`, () => { ) it(`agrees with the reference over a fixed campaign`, () => { - fc.assert(property, { numRuns: oracleRuns(2000), seed: 0x2029 }) + const parameters = oracleFixedSeed(0x2029, { numRuns: oracleRuns(2000) }) + oracleRoute(oracleRouteOf(parameters)) + fc.assert(property, parameters) }) it(`agrees with the reference over a random or replayed campaign`, () => { - fc.assert(property, oraclePropertyOptions(2000, PROPERTY)) + const campaignParameters = oraclePropertyOptions(2000, PROPERTY) + oracleRoute(oracleRouteOf(campaignParameters)) + fc.assert(property, campaignParameters) }) it.each([ @@ -259,28 +273,31 @@ describe(`in evaluator`, () => { false, ], [`an empty list`, `a`, [], false], - ] as const)(`pins %s`, (_name, value, list, expected) => { + ] as const)(`fixed history %s`, (_name, value, list, expected) => { + oracleRoute(`case`) const testCase = { value, list: [...list], listFromRow: false } expect(referenceIn(value, testCase.list)).toBe(expected) check(evaluateWithProduction, testCase) }) it(`rejects an evaluator that compares raw values`, () => { + oracleRoute(`calibration`) // Calibration: without normalization a Date never equals its timestamp // and equal byte arrays are different objects. const raw: Evaluate = ({ value, list }) => value === null || value === undefined ? null : list.some((entry) => entry === value) - expect(() => - fc.assert( - fc.property(inCase, (testCase) => check(raw, testCase)), - { numRuns: 500, seed: 0x2029 }, - ), - ).toThrow(/in mismatch/) + const details = fc.check( + fc.property(inCase, (testCase) => check(raw, testCase)), + { seed: 0x2029, numRuns: 500 }, + ) + expect(details.failed).toBe(true) + expect(String(details.errorInstance)).toMatch(/in mismatch/) }) it(`rejects display-text equality for equivalent Duration forms`, () => { + oracleRoute(`case`) const seconds = Temporal.Duration.from({ seconds: 1 }) const milliseconds = Temporal.Duration.from({ milliseconds: 1000 }) const textual: Evaluate = ({ value, list }) => diff --git a/packages/db/tests/query/custom-index-order-selection-oracle.test.ts b/packages/db/tests/query/custom-index-order-selection-oracle.test.ts index 3d578c29ce..b212cce4df 100644 --- a/packages/db/tests/query/custom-index-order-selection-oracle.test.ts +++ b/packages/db/tests/query/custom-index-order-selection-oracle.test.ts @@ -44,6 +44,7 @@ import { expectedIndexing, indexConfigurations, } from '../conformance/indexing-config-oracle.js' +import { oracleRoute } from '../oracle-config.js' import type { LoadSubsetOptions } from '../../src/types.js' import type { InitialQueryBuilder } from '../../src/query/builder/index.js' @@ -72,6 +73,13 @@ function expectRankWindow(actualIds: unknown): void { } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/live-query-collection.ts#liveQueryCollectionOptions', + ], check: [ expectRankWindow, expectMaterializedWindow, @@ -135,6 +143,7 @@ it.each( )( `keeps bounded public rank order with $mode $path`, async ({ mode, path }) => { + oracleRoute(`case`) const source = makeSource(mode, path) let live: ReturnType | undefined await withOracleCleanup(async () => { @@ -171,6 +180,7 @@ it.each( ) it(`uses a later eligible index after an unusable matching index`, async () => { + oracleRoute(`fixed history`) const requests: Array = [] const source = makeSource(`on-demand`, `scan`, (request) => requests.push(request), @@ -238,6 +248,7 @@ it(`uses a later eligible index after an unusable matching index`, async () => { }) it(`eager indexing creates an ordered reader when only a custom reader exists`, async () => { + oracleRoute(`fixed history`) const source = createCollection( mockSyncCollectionOptions({ id: `custom-index-order-selection-eager-auto`, @@ -363,6 +374,7 @@ function expectMaterializedWindow( it.each(configurations)( `applies output indexing: $path, mode=$autoIndex, constructor=$defaultIndexType.name`, async (settings) => { + oracleRoute(`case`) const expected = expectedIndexing(settings) const reach = createOracleReach( `live-query.output-indexing.${settings.path}`, @@ -488,6 +500,7 @@ it.each(configurations)( it.each( configurations.filter((settings) => !expectedIndexing(settings).rejects), )(`rejects wrong output-indexing observations: %j`, (settings) => { + oracleRoute(`calibration`) const expected = expectedMaterializedIndexes(settings) expect(() => expectMaterializedIndexes(expected.count + 1, settings), diff --git a/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts b/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts index 77ae3912d9..0cef0db067 100644 --- a/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts @@ -5,7 +5,13 @@ import { createDeferred } from '../../src/deferred.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { createTransaction } from '../../src/transactions.js' import { stripVirtualProps } from '../utils.js' -import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import type { SyncConfig } from '../../src/types.js' @@ -125,6 +131,11 @@ function expectedRowsAfterQueuedBatches(batches: number): Array { } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/transactions.ts#createTransaction', + ], check: [runFlushHistory], model: [expectedRowsAfterQueuedBatches], }) @@ -132,6 +143,7 @@ declareOracle({ it.each([`initial`, `update`, `insert`] as const)( `detects driver-only row mutation at the %s boundary`, async (phase) => { + oracleRoute(`calibration`) const steps = [{ inserts: 2, update: phase === `update` }] await expect( runFlushHistory(steps, (row, at) => { @@ -143,22 +155,33 @@ it.each([`initial`, `update`, `insert`] as const)( }, ) -fcTest.prop([flushHistory], { numRuns: oracleRuns(40), seed: 41703 })( +const flushHistoryFixedSeed = oracleFixedSeed(41703, { + numRuns: oracleRuns(40), +}) +fcTest.prop([flushHistory], flushHistoryFixedSeed)( `shares membership work only for balanced deltas across fixed queue histories`, - runFlushHistory, + (steps) => { + oracleRoute(oracleRouteOf(flushHistoryFixedSeed)) + return runFlushHistory(steps) + }, ) -fcTest.prop( - [flushHistory], - oraclePropertyOptions(60, `derived-publication.membership-work`), -)( +const flushHistoryCampaign = oraclePropertyOptions( + 60, + `derived-publication.membership-work`, +) +fcTest.prop([flushHistory], flushHistoryCampaign)( `shares membership work only for balanced deltas across random queue histories`, - runFlushHistory, + (steps) => { + oracleRoute(oracleRouteOf(flushHistoryCampaign)) + return runFlushHistory(steps) + }, ) describe(`derived updates beneath optimistic deletes`, () => { it.each(cases)( `$shape / layered=$layered / $outcome / $batches batches`, async ({ shape, layered, outcome, batches }) => { + oracleRoute(`case`) let sync!: Parameters[`sync`]>[0] const source = createCollection({ getKey: (row) => row.id, diff --git a/packages/db/tests/query/distinct-collision-oracle.test.ts b/packages/db/tests/query/distinct-collision-oracle.test.ts index 3b85e29431..f9bb003964 100644 --- a/packages/db/tests/query/distinct-collision-oracle.test.ts +++ b/packages/db/tests/query/distinct-collision-oracle.test.ts @@ -21,15 +21,25 @@ import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' import { declareOracle } from '../oracle-declaration.js' import { createOracleReach } from '../oracle-reach.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import type { DistinctValue } from '../../../db-ivm/tests/distinct-incrementalization-oracle.js' const PROPERTY = `query.distinct-collisions` const values = distinctCollisionValues(hash) -declareOracle({ check: [checkDistinctSnapshot], grammar: [distinctHistory] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + check: [checkDistinctSnapshot], + grammar: [distinctHistory], +}) for (const ordered of [false, true]) for (const campaign of [`fixed`, `random`] as const) @@ -37,6 +47,11 @@ for (const ordered of [false, true]) `preserves public distinct collision rows, ordered=${ordered} (${campaign})`, async () => { const reach = createOracleReach(PROPERTY, DISTINCT_REACH_STATES) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2306, { numRuns: oracleRuns(100) }) + : oraclePropertyOptions(100, PROPERTY) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(distinctHistory, async (worlds) => { reach.beginHistory() @@ -105,9 +120,7 @@ for (const ordered of [false, true]) () => source.cleanup(), ]) }), - campaign === `fixed` - ? { numRuns: oracleRuns(100), seed: 2306 } - : oraclePropertyOptions(100, PROPERTY), + parameters, ) reach.assertReached() process.stdout.write( diff --git a/packages/db/tests/query/group-result-key-oracle.property.test.ts b/packages/db/tests/query/group-result-key-oracle.property.test.ts index c3a03e53a7..95b4e59c5e 100644 --- a/packages/db/tests/query/group-result-key-oracle.property.test.ts +++ b/packages/db/tests/query/group-result-key-oracle.property.test.ts @@ -48,7 +48,13 @@ import fc from 'fast-check' import { createLiveQueryCollection } from '../../src/query/index.js' import { count } from '../../src/query/builder/functions.js' import { createCollection } from '../../src/collection/index.js' -import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../oracle-config.js' import { createOracleReach } from '../oracle-reach.js' import { declareOracle } from '../oracle-declaration.js' import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' @@ -129,7 +135,7 @@ const historyArbitrary: fc.Arbitrary = fc.record({ }) // Each lookalike pair publishes together, then one member's group changes. -const pinnedHistories: ReadonlyArray = [ +const fixedHistories: ReadonlyArray = [ { rows: [null, `null`, undefined, `undefined`], changes: [{ kind: `move`, index: 0, g: `null` }], @@ -258,12 +264,18 @@ async function runHistory(history: History, reach?: Reach): Promise { }, [() => live.cleanup(), () => source.cleanup()]) } -declareOracle({ check: [runHistory], model: [expectedGroups] }) +declareOracle({ + owns: ['src/query/live-query-collection.ts#createLiveQueryCollection'], + check: [runHistory], + model: [expectedGroups], +}) describe(`grouped result key oracle`, () => { - for (const [index, history] of pinnedHistories.entries()) { - it(`keeps lookalike group values apart (pinned ${index})`, () => - runHistory(history)) + for (const [index, history] of fixedHistories.entries()) { + it(`keeps lookalike group values apart (fixed history ${index})`, () => { + oracleRoute(`fixed history`) + return runHistory(history) + }) } it.each([92_604_118, undefined])( @@ -278,13 +290,16 @@ describe(`grouped result key oracle`, () => { ], { minimum: 5 }, ) + const parameters = + seed === undefined + ? oraclePropertyOptions(80, property) + : oracleFixedSeed(seed, { numRuns: oracleRuns(80) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(historyArbitrary, (history) => runHistory(history, reach), ), - seed === undefined - ? oraclePropertyOptions(80, property) - : { seed, numRuns: oracleRuns(80) }, + parameters, ) if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) console.log(property, seed ?? `random`, [...reach.counts()]) diff --git a/packages/db/tests/query/identity-output-shape-oracle.test.ts b/packages/db/tests/query/identity-output-shape-oracle.test.ts index 488a0737fe..4737878e22 100644 --- a/packages/db/tests/query/identity-output-shape-oracle.test.ts +++ b/packages/db/tests/query/identity-output-shape-oracle.test.ts @@ -22,7 +22,13 @@ import { UnionFrom, } from '../../src/query/ir.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' -import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { mockSyncCollectionOptions } from '../utils.js' import { assertEqualityPartition } from './identity-output-shape-support.js' @@ -330,12 +336,18 @@ describe('query identity agrees with compiled lexical output shape', () => { it.each(forms)( 'preserves full results and multiplicity for %s', async (form) => { + oracleRoute(`case`) await runShapes(form, ['user', 'post', 'account', 'article'], 'author') }, ) it.each([101600, undefined])( 'varies safe aliases and payloads, seed=%s', async (seed) => { + const parameters = + seed === undefined + ? oraclePropertyOptions(60, `query-identity.compiled-output`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(60) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.constantFrom(...forms), @@ -347,9 +359,7 @@ describe('query identity agrees with compiled lexical output shape', () => { (form, aliases, label) => runShapes(form, aliases as [string, string, string, string], label), ), - seed === undefined - ? oraclePropertyOptions(60, `query-identity.compiled-output`) - : { numRuns: oracleRuns(60), seed }, + parameters, ) }, ) @@ -362,6 +372,7 @@ describe('query identity agrees with compiled lexical output shape', () => { ] as const)( 'rejects captured %s against independent results', async (mutant) => { + oracleRoute(`calibration`) const form = mutant === 'missing-peer' ? 'explicit-join' : 'implicit-join' await runShapes(form, ['user', 'post', 'account', 'article'], 'author') await expect( @@ -457,14 +468,17 @@ describe(`equality-value and IR operand identity agree`, () => { it.each([20260928, undefined])( `agrees across the supported value grammar, seed=%s`, async (seed) => { + const campaignParameters = + seed === undefined + ? oraclePropertyOptions(160, `query-identity.equality-partition`) + : oracleFixedSeed(seed, { numRuns: oracleRuns(160) }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(equalityPairArbitrary, (pair) => { assertEqualityPartition(pair) return Promise.resolve() }), - seed === undefined - ? oraclePropertyOptions(160, `query-identity.equality-partition`) - : { numRuns: oracleRuns(160), seed }, + campaignParameters, ) }, ) @@ -502,6 +516,7 @@ function expectedCompoundJoinRows( describe(`compound join identity and output`, () => { for (const boundary of [`direct`, `from`, `joined`] as const) { it(`retains every equality across ${boundary}`, async () => { + oracleRoute(`case`) const leftRows = [ { id: 1, a: 1, b: 2, c: 9 }, { id: 2, a: 1, b: 3, c: 8 }, @@ -620,6 +635,16 @@ describe(`compound join identity and output`, () => { } }) declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/evaluators.ts#compileExpression', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/compiler/query-equivalence.ts#queriesMatchForCaching', + 'src/query/equality-value-identity.ts#getEqualityValueIdentity', + 'src/query/ir-stable-identity.ts#getLoadSubsetDemandKey', + 'src/query/ir-stable-identity.ts#getQueryIdentity', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [runShapes], model: [expectedShapeRows, expectedCompoundJoinRows], }) diff --git a/packages/db/tests/query/identity-output-shape.test.ts b/packages/db/tests/query/identity-output-shape.test.ts index dc31e81103..7a50e60160 100644 --- a/packages/db/tests/query/identity-output-shape.test.ts +++ b/packages/db/tests/query/identity-output-shape.test.ts @@ -1,4 +1,4 @@ -// Hand-written equality pairs that pin the value relation, and the public group +// Hand-written equality pairs that check the value relation, and the public group // rows they produce. identity-output-shape-oracle.test.ts owns the law and its // generated campaign. import { Temporal } from 'temporal-polyfill' diff --git a/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts b/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts index bfd6b0c795..53ef111e06 100644 --- a/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts +++ b/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts @@ -17,6 +17,7 @@ import { BasicIndex } from '../../src/indexes/basic-index.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { flushPromises } from '../utils.js' import { declareOracle } from '../oracle-declaration.js' +import { oracleRoute } from '../oracle-config.js' import { createScopedSource } from './includes-scope-identity-oracle.js' import { initialLocks, @@ -179,6 +180,16 @@ function expectRowsFollowModel( } declareOracle({ + owns: [ + 'src/collection/change-events.ts#createFilterFunctionFromExpression', + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/query/builder/index.ts', + 'src/query/builder/query-ir.ts#getQueryIR', + 'src/query/ir-stable-identity.ts#getQueryIdentity', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], check: [expectRowsFollowModel], model: [modelRows], }) @@ -352,6 +363,7 @@ describe(`captured alias scope oracle`, () => { for (const order of [`childFirst`, `parentFirst`] as const) { for (const mode of [`eager`, `onDemand`] as const) { test(`${form}, ${alias}, ${order}, ${mode}: exact rows follow source roles`, async () => { + oracleRoute(`case`) const locks = createScopedSource( `shadow-locks`, initialLocks, @@ -418,6 +430,7 @@ describe(`captured alias scope oracle`, () => { } test(`one captured child plan remains correct in two include positions`, async () => { + oracleRoute(`fixed history`) const locks = createScopedSource(`reuse-locks`, initialLocks, `eager`) const votes = createScopedSource(`reuse-votes`, initialVotes, `eager`) const live = createLiveQueryCollection({ @@ -484,6 +497,7 @@ describe(`captured alias scope oracle`, () => { for (const placement of [`queryRef`, `union`, `joined`] as const) { for (const mode of [`eager`, `onDemand`] as const) { test(`a shared ${placement} builder remains legal in a sibling include with ${mode} sources`, async () => { + oracleRoute(`case`) type Employee = { id: number; managerId: number } const employeeRows = new Map([ [1, { id: 1, managerId: 0 }], @@ -602,6 +616,7 @@ describe(`captured alias scope oracle`, () => { * source update; it never consults the builder's binding IDs. */ test(`fresh inner QueryRef declarations do not impersonate a captured parent`, async () => { + oracleRoute(`fixed history`) const rows = [ { id: 1, parentId: 1 }, { id: 2, parentId: 1 }, @@ -649,6 +664,7 @@ describe(`captured alias scope oracle`, () => { }) test(`a local child equality cannot impersonate a shadowed correlation`, async () => { + oracleRoute(`fixed history`) const locks = createScopedSource( `local-equality-locks`, initialLocks, @@ -701,6 +717,7 @@ describe(`captured alias scope oracle`, () => { }) test(`a nested include can capture its grandparent beside a shadowed child`, async () => { + oracleRoute(`fixed history`) const locks = createScopedSource(`nested-locks`, initialLocks, `eager`) const votes = createScopedSource(`nested-votes`, initialVotes, `eager`) const currentVotes = new Map(initialVotes.map((vote) => [vote.id, vote])) @@ -808,6 +825,7 @@ describe(`outer filters stay with their source under alias shadowing`, () => { for (const mode of [`eager`, `onDemand`] as const) { for (const childId of [1, 2] as const) { test(`${form}, ${mode}, child ID ${childId}: projected rows follow source roles`, async () => { + oracleRoute(`case`) const parentRow = { id: 1, linkId: childId } const childRow = { id: childId, linkId: 1 } const expected = model( @@ -945,6 +963,7 @@ describe(`captured projections keep their binding during predicate pushdown`, () for (const mode of [`eager`, `onDemand`] as const) { test(`${mode}: parent rank controls the child result under both child aliases`, async () => { + oracleRoute(`case`) const parentRow = { id: 1, rank: `A` } const childRow = { id: 10, parentId: 1, rank: `Z` } const anchorRow = { id: 10 } @@ -1122,6 +1141,7 @@ describe(`same-path group keys across alias scopes`, () => { for (const alias of [`parent`, `child`] as const) { for (const order of [`parentFirst`, `childFirst`] as const) { test(`group values retain ${alias} child binding with ${order} keys`, async () => { + oracleRoute(`case`) const parentRows = new Map(initialParents.map((row) => [row.id, row])) const childRows = new Map(initialChildren.map((row) => [row.id, row])) const parents = createScopedSource( @@ -1256,6 +1276,7 @@ describe(`group keys through nested source filtering`, () => { for (const innerAlias of [`x`, `u`] as const) { for (const mode of [`eager`, `onDemand`] as const) { test(`${mode} groups preserve the ${innerAlias} source through filtered joins and writes`, async () => { + oracleRoute(`case`) const sourceRows = new Map(initialSources.map((row) => [row.id, row])) const tagRows = new Map(initialTags.map((row) => [row.id, row])) const source = createScopedSource( @@ -1365,6 +1386,7 @@ declareOracle({ model: [expectedWrapperRows] }) * has the same identity. */ test(`optimized redundant wrappers erase alias spelling`, async () => { + oracleRoute(`fixed history`) const initial = { id: 1, label: `first` } const source = createScopedSource(`wrapper-alpha`, [initial], `eager`) const model = new Map([[initial.id, initial]]) @@ -1569,6 +1591,7 @@ describe(`lazy join demand across nested alias scopes`, () => { } test(`direct and wrapped shadowed includes preserve keyed user demand through writes`, async () => { + oracleRoute(`fixed history`) const anchors = new Map([[initialAnchor.id, initialAnchor]]) const users = new Map(initialUsers.map((user) => [user.id, user])) // In this one-way history, a key requested at an earlier checkpoint is @@ -1692,6 +1715,7 @@ describe(`lexical bindings through recursive joins and selections`, () => { test.each([`eager`, `onDemand`] as const)( `a shadowed FROM subquery preserves joined rows with %s loading`, async (mode) => { + oracleRoute(`case`) type Manager = { id: number; name: string } type User = { id: number; managerId: number; name: string } const managerRows: Array = [ @@ -1867,6 +1891,7 @@ describe(`nested source placement preserves captured outer rows`, () => { for (const capture of [`fields`, `spread`] as const) { for (const mode of [`eager`, `onDemand`] as const) { test(`${placement}, ${capture}, ${mode}: shadowing and renaming agree at every write cut`, async () => { + oracleRoute(`case`) const managersModel = new Map( initialManagers.map((row) => [row.id, row]), ) @@ -2181,6 +2206,7 @@ describe(`outer joins preserve absent source bindings under shadowing`, () => { ] as const) { for (const mode of [`eager`, `onDemand`] as const) { test(`${kind} with ${correlationSide} correlation and ${mode} sources keeps absent sides absent`, async () => { + oracleRoute(`case`) const parentRows = new Map([ [1, { id: 1, marker: `PARENT` }], [2, { id: 2, marker: `OTHER` }], @@ -2443,6 +2469,7 @@ describe(`chained joins keep an absent local source absent`, () => { for (const mode of [`eager`, `onDemand`] as const) { test(`${mode} chained joins preserve absence under shadowing and renaming`, async () => { + oracleRoute(`case`) const parentRows = new Map([ [1, { id: 1, label: `first parent` }], [2, { id: 2, label: `second parent` }], @@ -2674,6 +2701,7 @@ describe(`joined QueryRef lazy targets retain lexical source identity`, () => { for (const alias of [`u`, `post`] as const) { test(`${alias} post alias: projected tag drives on-demand rows`, async () => { + oracleRoute(`case`) const userRows = new Map([ [1, { id: 1, tagId: 7, name: `USER` }], ]) @@ -2904,6 +2932,7 @@ describe(`mixed outer-join chains retain absent source roles`, () => { for (const { first, second, correlation } of chains) { for (const mode of [`eager`, `onDemand`] as const) { test(`${first} then ${second}, ${correlation} correlation, ${mode} sources`, async () => { + oracleRoute(`case`) const parentRows = new Map([ [1, { id: 1, marker: `ONE` }], [2, { id: 2, marker: `TWO` }], @@ -3146,6 +3175,7 @@ describe(`captured ancestor join operands retain their bindings`, () => { for (const mode of [`eager`, `onDemand`] as const) { test(`${mode} sources preserve captured operands across alias and equality order`, async () => { + oracleRoute(`case`) const parentRows = new Map([[1, { id: 1, offset: 1 }]]) const childRows = new Map([ [10, { id: 10, parentId: 1, base: 6 }], @@ -3346,6 +3376,7 @@ describe(`grouped includes keep valid ancestor references`, () => { for (const mode of [`eager`, `onDemand`] as const) { test(`${mode} grouped includes use ancestors across shadowing and updates`, async () => { + oracleRoute(`case`) const parentRows = new Map([ [1, { id: 1, threshold: 0, direction: 1 }], [2, { id: 2, threshold: 0, direction: -1 }], @@ -3500,6 +3531,7 @@ describe(`joined subqueries reject captured sibling sources`, () => { for (const mode of [`eager`, `onDemand`] as const) { for (const alias of [`issue`, `inner`] as const) { test(`${mode} joined subquery may capture an actual ancestor with inner alias ${alias}`, async () => { + oracleRoute(`case`) const parentRows = [{ id: 1 }, { id: 2 }] const mainRows = [{ id: 1 }, { id: 2 }] const innerRows = [{ id: 1 }, { id: 2 }] 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 7b588b2f4c..54c796fe1c 100644 --- a/packages/db/tests/query/includes-collection-oracle.property.test.ts +++ b/packages/db/tests/query/includes-collection-oracle.property.test.ts @@ -13,8 +13,11 @@ import { import { runTrace } from '../trace-runner.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -80,9 +83,9 @@ import type { * * The history grammar varies parent and child writes, route movement, shared * buckets, dormant buckets, ordering, parent-dependent filters, and joins. It - * also varies optimistic confirmation and rollback. A bounded exhaustive lane covers every - * two-step history in the smallest relationship domain. Random histories add - * longer combinations and shrink failures. + * also varies optimistic confirmation and rollback. A bounded exhaustive + * campaign covers every two-step history in the smallest relationship domain. + * Random histories add longer combinations and shrink failures. * * The Map model stores current source Collection rows. It combines all D2 * relation, route, and bucket state into the public result recomputation; it @@ -91,15 +94,15 @@ import type { * * Grammar controls: the fixed route-retirement, dormant-bucket, shared-bucket, * and child-order witnesses reconstruct the key relationship histories. The - * generated action lane needs both parent and child writes: removing either + * generated action grammar needs both parent and child writes: removing either * loses route transitions or child-only facade changes. Its IDs (parents 0–3, * children 10–14), groups (-3–3), values (-5–5), and 1–16 actions are bounded; - * the exhaustive lane includes empty initial relations and zero actions. A + * the bounded exhaustive campaign includes empty initial relations and zero actions. A * repeated key becomes an update, so the grammar rejects a duplicate insert. - * The public-key lane crosses one-digit and two-digit IDs to expose lexical + * The public-key grammar crosses one-digit and two-digit IDs to expose lexical * sorting; allowing equal IDs would collapse the two-row premise. The order - * lane uses adjacent swaps among 2–8 rows; a one-row case cannot move. The - * optimistic lane uses group/value -10–10 and disjoint IDs 10–100/101–200. + * grammar uses adjacent swaps among 2–8 rows; a one-row case cannot move. The + * optimistic grammar uses group/value -10–10 and disjoint IDs 10–100/101–200. * Its rollback and confirmation steps distinguish temporary from retained * child rows; a delete of an absent child is excluded. * @@ -329,6 +332,11 @@ function expectedOrderedRows( // the expected events from the prior and current rows. The projection passes it // to every trace as `recompute`, so each history reaches it. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], model: [recompute, diffParents, expectedOrderedRows], }) @@ -383,7 +391,7 @@ const optimisticChildArbitrary = fc.record({ value: fc.integer({ min: -10, max: 10 }), }) -// Enumeration proves the small finite domain. The generated lane explores +// Enumeration proves the small finite domain. The generated campaign explores // longer histories over a wider value domain. Neither replaces the other. function enumerateActionSequences( actions: ReadonlyArray, @@ -421,6 +429,7 @@ describe(`Collection-valued includes oracle`, () => { fcTest( `root event observations reject missing, repeated, and corrupt changed-row events`, async () => { + oracleRoute(`calibration`) const driver = createCollectionDriver( [{ id: 1, group: 1 }], [{ id: 10, parentGroup: 1, value: 1 }], @@ -471,6 +480,7 @@ describe(`Collection-valued includes oracle`, () => { fcTest( `settles ${kind} after a checkpoint failure (rollback throws=${failRollback})`, async () => { + oracleRoute(`case`) const row = { id: 10, parentGroup: 1, value: 1 } const { driver, pending } = createOptimisticCollectionDriver( 1, @@ -558,25 +568,39 @@ describe(`Collection-valued includes oracle`, () => { projection: collectionProjection, }) - oracleReplayTest(fcTest).prop([collectionScenarioArbitrary], { + const collectionScenarioFixedSeed = oracleFixedSeed(1_658_101, { numRuns: oracleRuns(30), - seed: 1_658_101, - })( + }) + oracleReplayTest(fcTest).prop( + [collectionScenarioArbitrary], + collectionScenarioFixedSeed, + )( `keeps Collection, toArray, and materialize equivalent across fixed relationship histories`, - runRelationshipHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(collectionScenarioFixedSeed)) + return runRelationshipHistory(...args) + }, ) + const collectionScenarioCampaign = oraclePropertyOptions( + 30, + relationshipProperty, + ) oracleReplayTest(fcTest, relationshipProperty).prop( [collectionScenarioArbitrary], - oraclePropertyOptions(30, relationshipProperty), + collectionScenarioCampaign, )( `keeps Collection, toArray, and materialize equivalent across random or replayed relationship histories`, - runRelationshipHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(collectionScenarioCampaign)) + return runRelationshipHistory(...args) + }, ) fcTest( `exhaustively matches every two-step history in the smallest relationship domain`, async () => { + oracleRoute(`case`) const initialStates = [ { parents: [] as Array, children: [] as Array }, { parents: [{ id: 0, group: 0 }], children: [] as Array }, @@ -612,8 +636,9 @@ describe(`Collection-valued includes oracle`, () => { fcTest( `publishes independently compiled equivalent child relations coherently`, - () => - runTrace({ + () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [{ type: `deleteChild`, id: 10 }], driver: createCollectionDriver( [{ id: 0, group: 0 }], @@ -621,21 +646,27 @@ describe(`Collection-valued includes oracle`, () => { false, ), projection: collectionProjection, - }), + }) + }, ) - fcTest(`replays a dormant bucket when its first parent route activates`, () => - runTrace({ - steps: [{ type: `putParent`, row: { id: 1, group: 7 } }], - driver: createCollectionDriver( - [], - [{ id: 10, parentGroup: 7, value: 1 }], - ), - projection: collectionProjection, - }), + fcTest( + `replays a dormant bucket when its first parent route activates`, + () => { + oracleRoute(`fixed history`) + return runTrace({ + steps: [{ type: `putParent`, row: { id: 1, group: 7 } }], + driver: createCollectionDriver( + [], + [{ id: 10, parentGroup: 7, value: 1 }], + ), + projection: collectionProjection, + }) + }, ) fcTest(`retiring a route leaves a held facade empty and ready`, async () => { + oracleRoute(`fixed history`) let retiredFacade: IncludedChildCollection | undefined const heldSnapshots: Array<{ status: string | undefined @@ -708,6 +739,7 @@ describe(`Collection-valued includes oracle`, () => { }) fcTest(`a delete event preserves the published facade identity`, async () => { + oracleRoute(`fixed history`) let publishedFacade: IncludedChildCollection | undefined const events: Array = [] const eventSnapshots: Array> = [] @@ -772,8 +804,9 @@ describe(`Collection-valued includes oracle`, () => { assertDeleteIdentityEvents(eventSnapshots[1]!, true) }) - fcTest(`reactivating a retired route restores its current snapshot`, () => - runTrace({ + fcTest(`reactivating a retired route restores its current snapshot`, () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [ { type: `putParent`, row: { id: 1, group: 2 } }, { type: `putParent`, row: { id: 1, group: 1 } }, @@ -786,12 +819,13 @@ describe(`Collection-valued includes oracle`, () => { ], ), projection: collectionProjection, - }), - ) + }) + }) fcTest( `facade application failure leaves no partial state or publication`, async () => { + oracleRoute(`fixed history`) const driver = createCollectionDriver( [{ id: 1, group: 1 }], [ @@ -865,6 +899,7 @@ describe(`Collection-valued includes oracle`, () => { ) fcTest(`facade events observe the matching root publication`, async () => { + oracleRoute(`fixed history`) const driver = createCollectionDriver( [{ id: 1, group: 1 }], [ @@ -900,6 +935,7 @@ describe(`Collection-valued includes oracle`, () => { fcTest( `shared facades remain active until their last parent departs`, async () => { + oracleRoute(`fixed history`) const driver = createCollectionDriver( [ { id: 1, group: 1 }, @@ -1042,20 +1078,33 @@ describe(`Collection-valued includes oracle`, () => { ) } - oracleReplayTest(fcTest).prop([publicKeyOrderArbitrary], { + const publicKeyOrderFixedSeed = oracleFixedSeed(1_658_102, { numRuns: oracleRuns(20), - seed: 1_658_102, - })( + }) + oracleReplayTest(fcTest).prop( + [publicKeyOrderArbitrary], + publicKeyOrderFixedSeed, + )( `uses one raw public-key order across Collection and inline materializations with a fixed seed`, - runPublicKeyOrder, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(publicKeyOrderFixedSeed)) + return runPublicKeyOrder(...args) + }, ) + const publicKeyOrderCampaign = oraclePropertyOptions( + 20, + publicKeyOrderProperty, + ) oracleReplayTest(fcTest, publicKeyOrderProperty).prop( [publicKeyOrderArbitrary], - oraclePropertyOptions(20, publicKeyOrderProperty), + publicKeyOrderCampaign, )( `uses one raw public-key order across Collection and inline materializations with a random or replayed seed`, - runPublicKeyOrder, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(publicKeyOrderCampaign)) + return runPublicKeyOrder(...args) + }, ) const runLayoutSwap = async ({ @@ -1171,20 +1220,27 @@ describe(`Collection-valued includes oracle`, () => { ) } - oracleReplayTest(fcTest).prop([orderSwapArbitrary], { + const orderSwapFixedSeed = oracleFixedSeed(1_658_103, { numRuns: oracleRuns(20), - seed: 1_658_103, - })( + }) + oracleReplayTest(fcTest).prop([orderSwapArbitrary], orderSwapFixedSeed)( `propagates order-only child swaps through every materialization with a fixed seed`, - runLayoutSwap, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(orderSwapFixedSeed)) + return runLayoutSwap(...args) + }, ) + const orderSwapCampaign = oraclePropertyOptions(20, layoutSwapProperty) oracleReplayTest(fcTest, layoutSwapProperty).prop( [orderSwapArbitrary], - oraclePropertyOptions(20, layoutSwapProperty), + orderSwapCampaign, )( `propagates order-only child swaps through every materialization with a random or replayed seed`, - runLayoutSwap, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(orderSwapCampaign)) + return runLayoutSwap(...args) + }, ) for (const { intermediate, final } of [ @@ -1195,6 +1251,7 @@ describe(`Collection-valued includes oracle`, () => { fcTest( `a held ordered child repair publishes ${intermediate}-to-${final} through the facade`, async () => { + oracleRoute(`case`) type OrderedChild = { id: number parentGroup: number @@ -1405,6 +1462,7 @@ describe(`Collection-valued includes oracle`, () => { fcTest( `a held child truncate replay publishes an A-to-B-to-A reorder through the facade`, async () => { + oracleRoute(`fixed history`) type OrderedChild = { id: number parentGroup: number @@ -1588,6 +1646,7 @@ describe(`Collection-valued includes oracle`, () => { fcTest( `optimistic rollback comparison rejects a retained child row`, async () => { + oracleRoute(`calibration`) const row = { id: 10, parentGroup: 1, value: 2 } const { driver } = createOptimisticCollectionDriver(1) const context = await driver.setup() @@ -1613,20 +1672,33 @@ describe(`Collection-valued includes oracle`, () => { }, ) - oracleReplayTest(fcTest).prop([optimisticChildArbitrary], { + const optimisticChildFixedSeed = oracleFixedSeed(1_658_104, { numRuns: oracleRuns(20), - seed: 1_658_104, - })( + }) + oracleReplayTest(fcTest).prop( + [optimisticChildArbitrary], + optimisticChildFixedSeed, + )( `matches optimistic child settlement histories with a fixed seed`, - runOptimisticChildHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(optimisticChildFixedSeed)) + return runOptimisticChildHistory(...args) + }, ) + const optimisticChildCampaign = oraclePropertyOptions( + 20, + optimisticChildProperty, + ) oracleReplayTest(fcTest, optimisticChildProperty).prop( [optimisticChildArbitrary], - oraclePropertyOptions(20, optimisticChildProperty), + optimisticChildCampaign, )( `matches optimistic child settlement histories with a random or replayed seed`, - runOptimisticChildHistory, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(optimisticChildCampaign)) + return runOptimisticChildHistory(...args) + }, ) }) @@ -1641,6 +1713,7 @@ test.each([ `missing-group`, `active-status`, ] as const)(`rejects a hostile facade interval observation: %s`, (fault) => { + oracleRoute(`calibration`) const original: MetadataFacade = { id: `original`, status: `ready`, diff --git a/packages/db/tests/query/includes-collection.test.ts b/packages/db/tests/query/includes-collection.test.ts index af71b98182..844366cca3 100644 --- a/packages/db/tests/query/includes-collection.test.ts +++ b/packages/db/tests/query/includes-collection.test.ts @@ -1,7 +1,7 @@ // Fixed-scenario Collection-valued include tests: each runs one hand-written // history, or a fixed structural observation, and checks literal facade rows, // events, and states. These are unit tests. The generated facade laws, the -// Map model, and the exhaustive relationship lane are owned by +// Map model, and the bounded exhaustive relationship campaign are owned by // includes-collection-oracle.property.test.ts. import { test as fcTest } from '@fast-check/vitest' import { describe, expect } from 'vitest' diff --git a/packages/db/tests/query/includes-context-transport-oracle.test.ts b/packages/db/tests/query/includes-context-transport-oracle.test.ts index a2fac6890d..743173b6d8 100644 --- a/packages/db/tests/query/includes-context-transport-oracle.test.ts +++ b/packages/db/tests/query/includes-context-transport-oracle.test.ts @@ -34,6 +34,7 @@ import { sum, toArray, } from '../../src/query/index.js' +import { oracleRoute } from '../oracle-config.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { createGrammarCollection, @@ -2454,6 +2455,10 @@ async function runGrammarCell(cell: GrammarCell): Promise { // Each grammar cell is checked against the models below. Each expectation is // computed from the source Collections, not written as a value. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [runGrammarCell], model: [ expectedParentSnapshots, @@ -2477,6 +2482,7 @@ declareOracle({ describe(`correlated include route-context transport grammar`, () => { test(`expands every declared product without duplicate cells`, () => { + oracleRoute(`case`) const expectedCellCount = routeContextGrammar.parentProjection.shapes.length + routeContextGrammar.correlationDomain.states.length + @@ -2529,8 +2535,10 @@ describe(`correlated include route-context transport grammar`, () => { cell.family === `public-field` || cell.family === `public-field-overlap` ? `root and correlated forms × field admission stages` : `every materialization form × parent/child updates` - test(`${grammarCellName(cell)} × ${observations}`, () => - runGrammarCell(cell)) + test(`${grammarCellName(cell)} × ${observations}`, () => { + oracleRoute(`case`) + return runGrammarCell(cell) + }) } }) @@ -2576,6 +2584,7 @@ describe(`compound join parent context`, () => { for (const boundary of [`direct`, `queryRef`] as const) { for (const parentSide of [`main`, `joined`] as const) { test(`preserves the later parent operand through ${boundary}, ${parentSide}`, async () => { + oracleRoute(`case`) const parents = createGrammarCollection(`compound-parents`, [ { id: 1, group: 1, parameter: 1 }, { id: 2, group: 1, parameter: 2 }, @@ -2931,6 +2940,7 @@ declareOracle({ describe(`parent route after inherited inline input materialization`, () => { test(`expands every declared input boundary without duplicates`, () => { + oracleRoute(`case`) const expected = materializedInputGrammar.boundaries.length * materializedInputGrammar.selections.length * @@ -2947,6 +2957,7 @@ describe(`parent route after inherited inline input materialization`, () => { }) test(`rejects lost children and cross-parent reuse at the public checkpoint`, () => { + oracleRoute(`calibration`) const children = [ { id: 10, parentGroup: 1 }, { id: 20, parentGroup: 2 }, @@ -2971,8 +2982,10 @@ describe(`parent route after inherited inline input materialization`, () => { }) for (const cell of materializedInputCells) { - test(`${cell.boundary} / ${cell.selection} / ${cell.input}`, () => - runMaterializedInputCell(cell)) + test(`${cell.boundary} / ${cell.selection} / ${cell.input}`, () => { + oracleRoute(`case`) + return runMaterializedInputCell(cell) + }) } }) @@ -3042,6 +3055,7 @@ declareOracle({ check: [checkOuterJoinKeyRows], }) test(`rejects missing and duplicated joined contributions`, () => { + oracleRoute(`calibration`) const mains = [{ id: 10, link: 10, parentGroup: 1 }] const children = [ { id: 20, link: 10, parentGroup: 1 }, @@ -3058,6 +3072,7 @@ test(`rejects missing and duplicated joined contributions`, () => { test.each(outerJoinKeyGrammar)( `preserves outer-join child identities across independent source keys: %j`, async ({ join, mainRef, joinedRef, keys }) => { + oracleRoute(`case`) const cuts = [`initial`, `main-departed`, `main-returned`, `child-departed`] const reach = createOracleReach( `outer join independent source keys`, 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 1ea3892a4f..ff7a23b9b9 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,9 +16,12 @@ import { } from '../../src/query/index.js' import { oracleCalibrationTest, + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../oracle-config.js' @@ -594,7 +597,7 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( }), ) -// The three pinned examples below reconstruct the named in-range witnesses +// The three fixed histories below reconstruct the named in-range witnesses // in both generated campaigns. Each coordinate has positive probability in // the finite record grammar; examples keep them stable if sampling changes. // Group controls shared vs split/moved/unmatched routes; position controls @@ -707,6 +710,12 @@ const windowedExamples: Array< // each partition with a direct relation over source rows. The grouped check // compares a nested route's counts with the standalone grouped query. declareOracle({ + owns: [ + 'src/collection/change-events.ts#createFilterFunctionFromExpression', + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/query-once.ts#queryOnce', + ], check: [ expectFormulationsEquivalent, expectWindowedIncludeMatches, @@ -731,6 +740,7 @@ describe(`includes cross-formulation oracle`, () => { }) test(`bounded grammar axes distinguish routes, order, partitions, and windows`, () => { + oracleRoute(`fixed history`) const parents = new Map( partitionAndRouteScenario.parents.map((row) => [row.id, row]), ) @@ -821,6 +831,7 @@ describe(`includes cross-formulation oracle`, () => { }) test(`TLP checker rejects a wrong branch even when the union is complete`, () => { + oracleRoute(`calibration`) const rows: Array = [ { id: 10, parentGroup: 0, score: null, position: 0 }, { id: 11, parentGroup: 0, score: -1, position: 1 }, @@ -848,22 +859,30 @@ 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. - oracleReplayTest(fcTest).prop([fc.integer()], { + const fixedCampaign = oracleFixedSeed(1_658_001, { numRuns: oracleRuns(4), - seed: 1_658_001, - })( + }) + oracleReplayTest(fcTest).prop([fc.integer()], fixedCampaign)( `parent-context routing preserves reference-sensitive predicate values across transitions for a fixed seed`, async (code) => { + oracleRoute(oracleRouteOf(fixedCampaign)) await checkReferenceContextRouting(code) }, ) + const randomCampaign = oraclePropertyOptions( + 4, + `includes-cross-formulation.reference-context`, + ) oracleReplayTest(fcTest, `includes-cross-formulation.reference-context`).prop( [fc.integer()], - oraclePropertyOptions(4, `includes-cross-formulation.reference-context`), + randomCampaign, )( `parent-context routing preserves reference-sensitive predicate values across transitions for a random or replayed seed`, - checkReferenceContextRouting, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(randomCampaign)) + return checkReferenceContextRouting(...args) + }, ) async function checkReferenceContextRouting(code: number): Promise { @@ -1032,6 +1051,7 @@ describe(`includes cross-formulation oracle`, () => { ])( `grouped includes use query equality for %s routes`, async (_name, createValues) => { + oracleRoute(`case`) const [parentGroup, equivalentChildGroup, unequalChildGroup] = createValues() const parents = createControlledCollection(`equality-route-parents`, [ @@ -1101,20 +1121,30 @@ describe(`includes cross-formulation oracle`, () => { }, ) - oracleReplayTest(fcTest).prop([fc.integer()], { + const fixedCampaign2 = oracleFixedSeed(1_658_002, { numRuns: oracleRuns(4), - seed: 1_658_002, - })( + }) + oracleReplayTest(fcTest).prop([fc.integer()], fixedCampaign2)( `lazy materialization matches fully loaded materialization for reference-sensitive correlation keys for a fixed seed`, - checkReferenceKeyRouting, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(fixedCampaign2)) + return checkReferenceKeyRouting(...args) + }, ) + const randomCampaign2 = oraclePropertyOptions( + 4, + `includes-cross-formulation.reference-key`, + ) oracleReplayTest(fcTest, `includes-cross-formulation.reference-key`).prop( [fc.integer()], - oraclePropertyOptions(4, `includes-cross-formulation.reference-key`), + randomCampaign2, )( `lazy materialization matches fully loaded materialization for reference-sensitive correlation keys for a random or replayed seed`, - checkReferenceKeyRouting, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(randomCampaign2)) + return checkReferenceKeyRouting(...args) + }, ) async function checkReferenceKeyRouting(code: number): Promise { @@ -1262,23 +1292,30 @@ describe(`includes cross-formulation oracle`, () => { ]) } - oracleReplayTest(fcTest).prop([fc.integer()], { + const fixedCampaign3 = oracleFixedSeed(1_658_003, { numRuns: oracleRuns(4), - seed: 1_658_003, - })( + }) + oracleReplayTest(fcTest).prop([fc.integer()], fixedCampaign3)( `grouped includes agree with standalone groups for symbol routes for a fixed seed`, - checkSymbolGroupRouting, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(fixedCampaign3)) + return checkSymbolGroupRouting(...args) + }, ) + const randomCampaign3 = oraclePropertyOptions( + 4, + `includes-cross-formulation.symbol-group-route`, + ) oracleReplayTest( fcTest, `includes-cross-formulation.symbol-group-route`, - ).prop( - [fc.integer()], - oraclePropertyOptions(4, `includes-cross-formulation.symbol-group-route`), - )( + ).prop([fc.integer()], randomCampaign3)( `grouped includes agree with standalone groups for symbol routes for a random or replayed seed`, - checkSymbolGroupRouting, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(randomCampaign3)) + return checkSymbolGroupRouting(...args) + }, ) async function checkSymbolGroupRouting(code: number): Promise { @@ -1359,12 +1396,14 @@ describe(`includes cross-formulation oracle`, () => { } } - fcTest(`shared-route child deletion agrees across formulations`, () => - expectFormulationsEquivalent(sharedRouteDeletionScenario), - ) + fcTest(`shared-route child deletion agrees across formulations`, () => { + oracleRoute(`fixed history`) + return expectFormulationsEquivalent(sharedRouteDeletionScenario) + }) - test(`split, merge and reactivated routes preserve all predicate partitions`, () => - expectFormulationsEquivalent({ + test(`split, merge and reactivated routes preserve all predicate partitions`, () => { + oracleRoute(`fixed history`) + return expectFormulationsEquivalent({ parents: [ { id: 0, group: 0, position: 1 }, { id: 1, group: 0, position: 0 }, @@ -1391,7 +1430,8 @@ describe(`includes cross-formulation oracle`, () => { row: { id: 12, parentGroup: 1, score: 1, position: 0 }, }, ], - })) + }) + }) test.each([ { name: `zero limit`, offset: 0, limit: 0 }, @@ -1403,8 +1443,9 @@ describe(`includes cross-formulation oracle`, () => { { name: `empty tail`, offset: 3, limit: 2 }, ])( `preserves ordered window $name through root and child moves`, - ({ offset, limit }) => - expectWindowedIncludeMatches( + ({ offset, limit }) => { + oracleRoute(`case`) + return expectWindowedIncludeMatches( { parents: [ { id: 0, group: 0, position: 1 }, @@ -1435,12 +1476,13 @@ describe(`includes cross-formulation oracle`, () => { }, offset, limit, - ), + ) + }, ) // Checked direct replay witness: // TANSTACK_DB_ORACLE_SEED=1658004 - // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0 + // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 // TANSTACK_DB_ORACLE_PROPERTY=includes-cross-formulation.equivalence // TANSTACK_DB_ORACLE_CALIBRATION=missing-parent // pnpm --filter @tanstack/db exec vitest --run --coverage.enabled=false @@ -1454,6 +1496,7 @@ describe(`includes cross-formulation oracle`, () => { )( `generated equivalence rejects a missing production parent and replays its checkpoint`, async () => { + oracleRoute(`calibration`) // 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. @@ -1466,20 +1509,26 @@ describe(`includes cross-formulation oracle`, () => { property, directReplay ? { ...oraclePropertyOptions(8, propertyId), endOnFailure: true } - : { seed: 1_658_004, numRuns: oracleRuns(8) }, + : oracleFixedSeed(1_658_004, { numRuns: oracleRuns(8) }), ) expect(failure.failed).toBe(true) - expect(failure.error).toContain(`cross-formulation checkpoint`) - expect(failure.error).toContain(`checkpoint":0`) + expect(String(failure.errorInstance)).toContain( + `cross-formulation checkpoint`, + ) + expect(String(failure.errorInstance)).toContain(`checkpoint":0`) if (directReplay) { - expect(failure.seed).toBe(replay.replaySeed) - expect(replay.replayPath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0`) + expect(failure.seed).toBe(replay.campaignSeed) + expect(replay.replayPath).toBe( + `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, + ) return } expect(failure.numShrinks).toBeGreaterThan(0) if (failure.counterexamplePath === null) throw new Error(`missing-parent mutant did not produce a replay path`) - expect(failure.counterexamplePath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0`) + expect(failure.counterexamplePath).toBe( + `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, + ) const replayResult = await fc.check(property, { seed: failure.seed, path: failure.counterexamplePath, @@ -1487,15 +1536,17 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`cross-formulation checkpoint`) - expect(replayResult.error).toContain(`checkpoint":0`) + expect(String(replayResult.errorInstance)).toContain( + `cross-formulation checkpoint`, + ) + expect(String(replayResult.errorInstance)).toContain(`checkpoint":0`) expect(replayResult.counterexample).toEqual(failure.counterexample) }, ) // Checked direct replay witness: // TANSTACK_DB_ORACLE_SEED=1658005 - // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 + // TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0 // TANSTACK_DB_ORACLE_PROPERTY=includes-cross-formulation.ordered-window // TANSTACK_DB_ORACLE_CALIBRATION=extra-child // pnpm --filter @tanstack/db exec vitest --run --coverage.enabled=false @@ -1509,6 +1560,7 @@ describe(`includes cross-formulation oracle`, () => { )( `generated window rejects an extra production child and replays its checkpoint`, async () => { + oracleRoute(`calibration`) const property = fc.asyncProperty( windowedScenarioArbitrary, async (sample) => { @@ -1531,21 +1583,23 @@ describe(`includes cross-formulation oracle`, () => { property, directReplay ? { ...oraclePropertyOptions(12, propertyId), endOnFailure: true } - : { seed: 1_658_005, numRuns: oracleRuns(12) }, + : oracleFixedSeed(1_658_005, { numRuns: oracleRuns(12) }), ) expect(failure.failed).toBe(true) - expect(failure.error).toContain(`ordered-window checkpoint`) - expect(failure.error).toContain(`checkpoint":0`) + expect(String(failure.errorInstance)).toContain( + `ordered-window checkpoint`, + ) + expect(String(failure.errorInstance)).toContain(`checkpoint":0`) if (directReplay) { - expect(failure.seed).toBe(replay.replaySeed) - expect(replay.replayPath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`) + expect(failure.seed).toBe(replay.campaignSeed) + expect(replay.replayPath).toBe(`0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`) return } expect(failure.numShrinks).toBeGreaterThan(0) if (failure.counterexamplePath === null) throw new Error(`extra-child mutant did not produce a replay path`) expect(failure.counterexamplePath).toBe( - `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, + `0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0`, ) const replayResult = await fc.check(property, { seed: failure.seed, @@ -1554,8 +1608,10 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`ordered-window checkpoint`) - expect(replayResult.error).toContain(`checkpoint":0`) + expect(String(replayResult.errorInstance)).toContain( + `ordered-window checkpoint`, + ) + expect(String(replayResult.errorInstance)).toContain(`checkpoint":0`) expect(replayResult.counterexample).toEqual(failure.counterexample) }, ) @@ -1570,24 +1626,31 @@ describe(`includes cross-formulation oracle`, () => { : undefined, ) - oracleReplayTest(fcTest).prop([scenarioArbitrary], { + const scenarioFixedSeed = oracleFixedSeed(1_658_004, { numRuns: oracleRuns(8), - seed: 1_658_004, examples: scenarioExamples, - })( + }) + oracleReplayTest(fcTest).prop([scenarioArbitrary], scenarioFixedSeed)( `agrees across nested includes, flat joins, per-parent queries, and TLP partitions for a fixed seed`, - checkEquivalenceScenario, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(scenarioFixedSeed)) + return checkEquivalenceScenario(...args) + }, ) + const scenarioCampaign = { + ...oraclePropertyOptions(8, `includes-cross-formulation.equivalence`), + examples: oracleReplayRequested() ? [] : scenarioExamples, + } oracleReplayTest(fcTest, `includes-cross-formulation.equivalence`).prop( [scenarioArbitrary], - { - ...oraclePropertyOptions(8, `includes-cross-formulation.equivalence`), - examples: oracleReplayRequested() ? [] : scenarioExamples, - }, + scenarioCampaign, )( `agrees across nested includes, flat joins, per-parent queries, and TLP partitions for a random or replayed seed`, - checkEquivalenceScenario, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(scenarioCampaign)) + return checkEquivalenceScenario(...args) + }, ) const checkWindowedScenario = ({ @@ -1618,23 +1681,33 @@ describe(`includes cross-formulation oracle`, () => { : undefined, ) - oracleReplayTest(fcTest).prop([windowedScenarioArbitrary], { + const windowedScenarioFixedSeed = oracleFixedSeed(1_658_005, { numRuns: oracleRuns(12), - seed: 1_658_005, examples: windowedExamples, - })( + }) + oracleReplayTest(fcTest).prop( + [windowedScenarioArbitrary], + windowedScenarioFixedSeed, + )( `matches recomputation for ordered offset and limit child windows for a fixed seed`, - checkWindowedScenario, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(windowedScenarioFixedSeed)) + return checkWindowedScenario(...args) + }, ) + const windowedScenarioCampaign = { + ...oraclePropertyOptions(12, `includes-cross-formulation.ordered-window`), + examples: oracleReplayRequested() ? [] : windowedExamples, + } oracleReplayTest(fcTest, `includes-cross-formulation.ordered-window`).prop( [windowedScenarioArbitrary], - { - ...oraclePropertyOptions(12, `includes-cross-formulation.ordered-window`), - examples: oracleReplayRequested() ? [] : windowedExamples, - }, + windowedScenarioCampaign, )( `matches recomputation for ordered offset and limit child windows for a random or replayed seed`, - checkWindowedScenario, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(windowedScenarioCampaign)) + return checkWindowedScenario(...args) + }, ) }) diff --git a/packages/db/tests/query/includes-cross-formulation.test.ts b/packages/db/tests/query/includes-cross-formulation.test.ts index deb0992fd4..d606a5913d 100644 --- a/packages/db/tests/query/includes-cross-formulation.test.ts +++ b/packages/db/tests/query/includes-cross-formulation.test.ts @@ -376,7 +376,9 @@ describe(`includes cross-formulation oracle`, () => { const failure = fc.check(property, { seed: 1_658_002, numRuns: 4 }) expect(failure.failed).toBe(true) expect(failure.numShrinks).toBeGreaterThan(0) - expect(failure.error).toContain(`reference route checkpoint`) + expect(String(failure.errorInstance)).toContain( + `reference route checkpoint`, + ) if (failure.counterexamplePath === null) throw new Error(`reference-route mutant did not produce a replay path`) const replayResult = fc.check(property, { @@ -386,7 +388,9 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`reference route checkpoint`) + expect(String(replayResult.errorInstance)).toContain( + `reference route checkpoint`, + ) expect(replayResult.counterexample).toEqual(failure.counterexample) }) @@ -406,7 +410,7 @@ describe(`includes cross-formulation oracle`, () => { const failure = fc.check(property, { seed: 1_658_003, numRuns: 4 }) expect(failure.failed).toBe(true) expect(failure.numShrinks).toBeGreaterThan(0) - expect(failure.error).toContain(`group-route checkpoint`) + expect(String(failure.errorInstance)).toContain(`group-route checkpoint`) if (failure.counterexamplePath === null) throw new Error(`symbol-group mutant did not produce a replay path`) const replayResult = fc.check(property, { @@ -416,7 +420,9 @@ describe(`includes cross-formulation oracle`, () => { endOnFailure: true, }) expect(replayResult.failed).toBe(true) - expect(replayResult.error).toContain(`group-route checkpoint`) + expect(String(replayResult.errorInstance)).toContain( + `group-route checkpoint`, + ) expect(replayResult.counterexample).toEqual(failure.counterexample) }) diff --git a/packages/db/tests/query/includes-functional-projection-oracle.test.ts b/packages/db/tests/query/includes-functional-projection-oracle.test.ts index 203d29af67..ca6bd7ca7c 100644 --- a/packages/db/tests/query/includes-functional-projection-oracle.test.ts +++ b/packages/db/tests/query/includes-functional-projection-oracle.test.ts @@ -7,6 +7,7 @@ import { materialize, toArray, } from '../../src/query/index.js' +import { oracleRoute } from '../oracle-config.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { Projection, @@ -64,6 +65,10 @@ import type { */ declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], model: [expectedGroupChildren, expectedOperatorRows], grammar: [cells, orderedPairCells, valueCells, renamedCells, operatorCells], }) @@ -102,6 +107,7 @@ function expectedOperatorRows( describe(`functional projection output compatibility`, () => { it(`covers the declared output and renamed-field products`, () => { + oracleRoute(`case`) expect(valueCells).toHaveLength(48) expect(renamedCells).toHaveLength(24) expect(operatorCells).toHaveLength(18) @@ -119,6 +125,7 @@ describe(`functional projection output compatibility`, () => { it.each(operatorCells)( `$form / $operator / reads-include=$readsInclude consumes the projected value`, async ({ form, operator, readsInclude }) => { + oracleRoute(`case`) const parents = createControlledCollection(`operator-parent`, [ { id: 1, group: 1, base: 3 }, { id: 2, group: 2, base: 5 }, @@ -246,6 +253,7 @@ describe(`functional projection output compatibility`, () => { it.each(renamedCells)( `$form / $projection / $consumer / sibling=$withSibling materializes inputs before renaming them`, async ({ form, consumer, projection, withSibling }) => { + oracleRoute(`case`) const parents = createControlledCollection(`renamed-parent`, [ { id: 1, group: 1, siblingGroup: 2 }, ]) @@ -473,6 +481,7 @@ describe(`functional projection output compatibility`, () => { describe(`functional include projection boundary grammar`, () => { it(`covers every declared boundary product without duplicate cells`, () => { + oracleRoute(`case`) expect(cells).toHaveLength(54) expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(54) expect(orderedPairCells).toHaveLength(27) @@ -486,6 +495,7 @@ describe(`functional include projection boundary grammar`, () => { it.each([...cells, ...orderedPairCells])( `$boundary / $form / $output / $initial`, async ({ boundary, form, output, initial }) => { + oracleRoute(`case`) const parents = createControlledCollection(`projection-parents`, [ { id: 1, group: 1 }, ]) 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 70f1a42315..475414d7e6 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,13 @@ 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 { + oracleFixedSeed, + oraclePropertyOptions, + oracleReplayTest, + oracleRoute, + oracleRouteOf, +} from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { assertCanStartOptimisticChange, @@ -629,6 +635,12 @@ function expectedHeldRepairRows( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [expectHistoryMatches], model: [recompute, expectedHeldRepairRows], }) @@ -652,13 +664,20 @@ function optimisticProperty( const options = oraclePropertyOptions(12, property) // 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) + const routeValuesFixedSeed = oracleFixedSeed(1_658_013, { ...options }) + oracleReplayTest(fcTest).prop([routeValuesArbitrary], routeValuesFixedSeed)( + `${name} (fixed seed)`, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(routeValuesFixedSeed)) + return run(...args) + }, + ) oracleReplayTest(fcTest, property).prop([routeValuesArbitrary], options)( `${name} (random seed)`, - run, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(options)) + return run(...args) + }, ) } @@ -666,6 +685,7 @@ describe(`optimistic relationship-transition oracle`, () => { fcTest( `keeps a same-root optimistic snapshot private until ordered repair publishes`, async () => { + oracleRoute(`fixed history`) type OrderedRoot = { id: number; rank: number; value: string } type Child = { id: number; parentId: number } type PublishedRoot = { @@ -913,6 +933,7 @@ describe(`optimistic relationship-transition oracle`, () => { fcTest( `observes queued sibling delivery with pending=${pending}`, async () => { + oracleRoute(`case`) const routes: RouteValues = { rootA: 10, rootB: 100, @@ -1088,6 +1109,7 @@ describe(`optimistic relationship-transition oracle`, () => { fcTest( `cleans failed optimistic work (compound=${compound}, cleanup failure=${cleanupFails})`, async () => { + oracleRoute(`case`) const routes: RouteValues = { rootA: 10, rootB: 100, @@ -1194,6 +1216,7 @@ describe(`optimistic relationship-transition oracle`, () => { fcTest( `checks the optimistic overlay before yielding from apply`, async () => { + oracleRoute(`fixed history`) const routes: RouteValues = { rootA: 10, rootB: 100, @@ -1236,6 +1259,7 @@ describe(`optimistic relationship-transition oracle`, () => { fcTest( `rejects a stale route at the immediate optimistic checkpoint`, async () => { + oracleRoute(`calibration`) const routes: RouteValues = { rootA: 10, rootB: 100, diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index efef085603..bdad879d15 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -15,9 +15,12 @@ import { withOracleCleanup, } from '../utils.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplaySkips, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -223,7 +226,10 @@ function generatedCampaigns(runs: number, property: string, fixedSeed: number) { } return [ - { label: `fixed`, options: { numRuns: oracleRuns(runs), seed: fixedSeed } }, + { + label: `fixed`, + options: oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(runs) }), + }, { label: `random`, options: oraclePropertyOptions(runs, property) }, ] } @@ -494,6 +500,14 @@ function recompute( // Collection delivery metadata is independent of include materialization. // Compare only the user-defined row shape modeled by the recompute oracle. declareOracle({ + owns: [ + 'src/collection/change-events.ts#createFilterFunctionFromExpression', + 'src/collection/index.ts#createCollection', + 'src/query/equality-value-identity.ts#createValueIdentity', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/live/public-key-reduction.ts#routeKey', + 'src/query/query-once.ts#queryOnce', + ], check: [ expectScenarioMatches, expectFullRowBatchScenarioMatches, @@ -1496,7 +1510,7 @@ function transitionHistoryPlacements( }> { // A rekey needs one child level so it changes relationship membership. It // also fails when two descendant levels remain. The single-transition matrix - // pins that failure and will turn red when this continuation can be widened. + // covers that failure and will turn red when this continuation can be widened. const minimumTargetLevel = firstTransition === `rekey` || secondTransition === `rekey` ? Math.max(1, depth - 1) @@ -3193,6 +3207,7 @@ describe(`includes recompute oracle`, () => { fcTest( `distinguishes optimistic syntax from delivered rollback and no-op cuts`, async () => { + oracleRoute(`fixed history`) const driver = createStructuralTraceDriver(1) const row = { id: 1, parentGroup: 1, group: 1, value: 1, position: 0 } const history: Array = [ @@ -3266,6 +3281,7 @@ describe(`includes recompute oracle`, () => { fcTest( `checks ordinary root and child changes before yielding from apply`, async () => { + oracleRoute(`fixed history`) const driver = createStructuralTraceDriver(1) const row = { id: 1, parentGroup: 1, group: 1, value: 1, position: 0 } const history: Array = [ @@ -3298,6 +3314,7 @@ describe(`includes recompute oracle`, () => { fcTest( `rejects an omitted child at its first public checkpoint`, async () => { + oracleRoute(`calibration`) const row = { id: 1, parentGroup: 1, group: 1, value: 1, position: 0 } await expect( runTrace({ @@ -3330,6 +3347,7 @@ describe(`includes recompute oracle`, () => { fcTest( `cleans failed optimistic checkpoint at level ${level} with cleanup failure=${cleanupFails}`, async () => { + oracleRoute(`case`) const driver = createStructuralTraceDriver(1) const failure = new Error(`Core checkpoint failed`) const cleanupFailure = new Error( @@ -3401,6 +3419,7 @@ describe(`includes recompute oracle`, () => { } fcTest(`the sibling topology targets rows under one parent`, () => { + oracleRoute(`fixed history`) const branches = transitionHistoryBranches const prefix = independentTransitionPrefix(`sibling`, branches) const levelTwo = prefix.find((step) => step.level === 2) @@ -3417,6 +3436,7 @@ describe(`includes recompute oracle`, () => { }) fcTest(`the descendant remains attached before its ancestor moves`, () => { + oracleRoute(`fixed history`) const branches = transitionHistoryBranches const scenario = createRouteLifecycleScenario({ depth: 3, @@ -3446,6 +3466,7 @@ describe(`includes recompute oracle`, () => { }) fcTest(`reconstructs every supported two-transition placement`, () => { + oracleRoute(`fixed seed`) // The generated green product is depth 1–4, both transition kinds in // both positions, and every supported insertion level. Rekey needs a // child level. Rekey with two descendant levels has narrower explicit @@ -3464,7 +3485,7 @@ describe(`includes recompute oracle`, () => { ) const [generated] = fc.sample( transitionHistoryScenariosArbitrary(depth, first, second, 0), - { seed: 1725, numRuns: 1 }, + oracleFixedSeed(1725, { numRuns: 1 }), ) expect(generated).toHaveLength(placements.length) for (const [index, scenario] of generated!.entries()) { @@ -3503,6 +3524,7 @@ describe(`includes recompute oracle`, () => { fcTest( `keeps each route-history topology and destination in the grammar`, () => { + oracleRoute(`fixed history`) expect(independentTransitionShapes).toEqual([ `ancestor-descendant`, `descendant-ancestor`, @@ -3589,6 +3611,7 @@ describe(`includes recompute oracle`, () => { ) fcTest(`reconstructs and distinguishes the split/atomic batch matrix`, () => { + oracleRoute(`fixed history`) const fixture = { leftIdBase: 100, leftGroupBase: 600, @@ -3702,11 +3725,12 @@ describe(`includes recompute oracle`, () => { }) fcTest(`keeps retired-route and moved-child matrix cuts distinct`, () => { + oracleRoute(`fixed seed`) for (const depth of [2, 3, 4] as const) { for (const sourceBranch of [0, 1] as const) { const [split] = fc.sample( rekeyRouteReuseScenarioArbitrary(depth, sourceBranch), - { seed: 1726, numRuns: 1 }, + oracleFixedSeed(1726, { numRuns: 1 }), ) const [atomic] = fc.sample( rekeyRouteReuseScenarioArbitrary( @@ -3714,7 +3738,7 @@ describe(`includes recompute oracle`, () => { sourceBranch, createIntraBatchRekeyRouteReuseScenarios, ), - { seed: 1733, numRuns: 1 }, + oracleFixedSeed(1733, { numRuns: 1 }), ) expect(split).toBeDefined() expect(atomic).toBeDefined() @@ -3754,7 +3778,7 @@ describe(`includes recompute oracle`, () => { targetLevel, sourceBranch, ), - { seed: 1727, numRuns: 1 }, + oracleFixedSeed(1727, { numRuns: 1 }), ) for (const order of branchDeliveryOrders) { const pair = mirrors![order] @@ -3783,6 +3807,7 @@ describe(`includes recompute oracle`, () => { fcTest( `a root entering a live route receives its ordered snapshot`, async () => { + oracleRoute(`fixed history`) const branches = transitionHistoryBranches const prefix = createConnectedBatchBranches(1, branches) const childStep = prefix.find((step) => step.level === 1) @@ -3837,6 +3862,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop([arbitrary], options)( `matches recomputation for independent ${shape} relationship targets [${property}, ${label}]`, async (scenario) => { + oracleRoute(oracleRouteOf(options)) expectEveryRouteTransitionVisible(scenario) await expectFullRowBatchScenarioMatches(scenario) }, @@ -3855,6 +3881,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop([arbitrary], options)( `matches recomputation for the ${history} route destination history [${property}, ${label}]`, async (scenario) => { + oracleRoute(oracleRouteOf(options)) expectEveryRouteTransitionVisible(scenario) await expectFullRowBatchScenarioMatches(scenario) }, @@ -3866,45 +3893,54 @@ describe(`includes recompute oracle`, () => { for (const enteringRow of [0, 1] as const) { fcTest( `matches recomputation when level-${parentLevel} row ${enteringRow} enters a live shared route`, - () => - expectHistoryScenarioPairMatches( + () => { + oracleRoute(`case`) + return expectHistoryScenarioPairMatches( createMergeIntoSharedRouteScenarios(parentLevel, enteringRow), - ), + ) + }, ) } fcTest( `matches recomputation when the last level-${parentLevel} shared-route subscriber leaves`, - () => - expectFullRowBatchScenarioMatches( + () => { + oracleRoute(`case`) + return expectFullRowBatchScenarioMatches( createSharedRouteLastSubscriberScenario(parentLevel), - ), + ) + }, ) fcTest( `matches recomputation after a level-${parentLevel} route resubscribes`, - () => - expectHistoryScenarioPairMatches( + () => { + oracleRoute(`case`) + return expectHistoryScenarioPairMatches( createSnapshotOnResubscribeScenarios(parentLevel), - ), + ) + }, ) fcTest( `matches recomputation when an initially shared level-${parentLevel} route retires, changes, and resubscribes`, - () => - expectHistoryScenarioPairMatches( + () => { + oracleRoute(`case`) + return expectHistoryScenarioPairMatches( createInitiallySharedRouteResubscribeScenario(parentLevel), - ), + ) + }, ) } for (const materialization of [`array`, `concat`] as const) { - fcTest(`${materialization} follows an intra-batch child hand-off`, () => - expectFlatMaterializationScenarioMatches( + fcTest(`${materialization} follows an intra-batch child hand-off`, () => { + oracleRoute(`case`) + return expectFlatMaterializationScenarioMatches( materialization, intraBatchChildHandOffScenario, - ), - ) + ) + }) } for (const [depth, targetLevel] of [ @@ -3914,33 +3950,49 @@ describe(`includes recompute oracle`, () => { ] as const) { fcTest( `later updates propagate through a reparented subtree at depth ${depth}, level ${targetLevel}`, - () => - expectFullRowBatchScenarioMatches( + () => { + oracleRoute(`case`) + return expectFullRowBatchScenarioMatches( createReparentedSubtreeUpdateScenario(depth, targetLevel), - ), + ) + }, ) } - fcTest(`rekeying a row detaches two descendant levels`, () => - expectFullRowBatchScenarioMatches(minimalRekeyScenario), - ) + fcTest(`rekeying a row detaches two descendant levels`, () => { + oracleRoute(`fixed history`) + return expectFullRowBatchScenarioMatches(minimalRekeyScenario) + }) - fcTest(`reusing a rekeyed row's old route does not resurrect its child`, () => - expectFullRowBatchScenarioMatches(rekeyRouteResurrectionScenario), + fcTest( + `reusing a rekeyed row's old route does not resurrect its child`, + () => { + oracleRoute(`fixed history`) + return expectFullRowBatchScenarioMatches(rekeyRouteResurrectionScenario) + }, ) fcTest( `matches recomputation when sharing a route without rekeying its existing row`, - () => expectFullRowBatchScenarioMatches(rekeyRouteReuseControl), + () => { + oracleRoute(`fixed history`) + return expectFullRowBatchScenarioMatches(rekeyRouteReuseControl) + }, ) - fcTest(`replacing a moved subtree child retains its grandchild`, () => - expectFullRowBatchScenarioMatches(movedSubtreeChildReplacementScenario), - ) + fcTest(`replacing a moved subtree child retains its grandchild`, () => { + oracleRoute(`fixed history`) + return expectFullRowBatchScenarioMatches( + movedSubtreeChildReplacementScenario, + ) + }) fcTest( `matches recomputation when replacing a child without reparenting its ancestor`, - () => expectFullRowBatchScenarioMatches(childReplacementControl), + () => { + oracleRoute(`fixed history`) + return expectFullRowBatchScenarioMatches(childReplacementControl) + }, ) for (const depth of [2, 3, 4] as const) { @@ -3957,7 +4009,10 @@ describe(`includes recompute oracle`, () => { )) { fcTest.prop([retiredArbitrary], options)( `reuses a retired route at depth ${depth}, branch ${sourceBranch} [${retiredProperty}, ${label}]`, - expectHistoryScenarioPairMatches, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(options)) + return expectHistoryScenarioPairMatches(...args) + }, ) } @@ -3974,7 +4029,10 @@ describe(`includes recompute oracle`, () => { )) { fcTest.prop([intraBatchArbitrary], options)( `handles intra-batch rekey then retired-route reuse at depth ${depth}, branch ${sourceBranch} [${intraBatchProperty}, ${label}]`, - expectHistoryScenarioPairMatches, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(options)) + return expectHistoryScenarioPairMatches(...args) + }, ) } } @@ -3983,10 +4041,12 @@ describe(`includes recompute oracle`, () => { for (const parentLevel of [0, 1] as const) { fcTest( `a departed level-${parentLevel} shared-route subscriber ignores later child updates`, - () => - expectHistoryScenarioPairMatches( + () => { + oracleRoute(`case`) + return expectHistoryScenarioPairMatches( createSharedRouteLifetimeScenarios(parentLevel), - ), + ) + }, ) } @@ -4007,6 +4067,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop([arbitrary], options)( `matches forward/reverse delivery mirrors when replacing a moved child at depth ${depth}, level ${targetLevel}, source ${sourceBranch} [${property}, ${label}]`, async (scenarios) => { + oracleRoute(oracleRouteOf(options)) for (const deliveryOrder of branchDeliveryOrders) { await expectHistoryScenarioPairMatches(scenarios[deliveryOrder]) } @@ -4035,6 +4096,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop([relationshipBatchFixtureArbitrary], options)( `matches the split/atomic replacement matrix for ${publicId} public id, ${route} route, ${ancestorUpdate} [${property}, ${label}]`, async (fixture) => { + oracleRoute(oracleRouteOf(options)) const cells = createRelationshipBatchShapeMatrix( fixture, publicId, @@ -4083,8 +4145,10 @@ describe(`includes recompute oracle`, () => { )) { fcTest.prop(flatMaterializationArbitraries, options)( `matches recomputation for flat materializations [${flatMaterializationProperty}, ${label}]`, - (kind, scenario) => - expectFlatMaterializationScenarioMatches(kind, scenario), + (kind, scenario) => { + oracleRoute(oracleRouteOf(options)) + return expectFlatMaterializationScenarioMatches(kind, scenario) + }, ) } @@ -4098,7 +4162,10 @@ describe(`includes recompute oracle`, () => { )) { fcTest.prop([arbitrary], options)( `matches recomputation for visible multi-row batches at depth ${depth} [${property}, ${label}]`, - expectFullRowBatchScenarioMatches, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(options)) + return expectFullRowBatchScenarioMatches(...args) + }, ) } @@ -4122,6 +4189,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop([visibleArbitrary], options)( `matches recomputation for a visible ${transition} at depth ${depth}, level ${targetLevel} [${visibleProperty}, ${label}]`, async (scenarios) => { + oracleRoute(oracleRouteOf(options)) for (const scenario of [ scenarios.transitionOnly, scenarios.stateful, @@ -4178,6 +4246,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop([arbitrary], options)( `matches recomputation for ${firstTransition} → ${secondTransition} histories at depth ${depth}, branch ${sourceBranch} [${property}, ${label}]`, async (scenarios) => { + oracleRoute(oracleRouteOf(options)) for (const scenario of scenarios) { expectEveryHistoryStepVisible(scenario) await expectFullRowBatchScenarioMatches(scenario) @@ -4203,6 +4272,7 @@ describe(`includes recompute oracle`, () => { fcTest( `matches composed ${firstTransition} → ${secondTransition} with two descendant levels at depth ${depth}, branch ${sourceBranch}`, async () => { + oracleRoute(`case`) const childLevel = (targetLevel + 1) as IncludeDepth const scenario = createTransitionHistoryScenario({ depth, @@ -4224,8 +4294,9 @@ describe(`includes recompute oracle`, () => { } } - fcTest(`nested scalar materialization follows a reference update`, () => - runTrace({ + fcTest(`nested scalar materialization follows a reference update`, () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [ { type: `insert`, insert: `root-1` }, { type: `insert`, insert: `middle-1` }, @@ -4235,10 +4306,11 @@ describe(`includes recompute oracle`, () => { ], driver: createMaterializeTraceDriver(false), projection: materializeProjection, - }), - ) + }) + }) fcTest(`matches recomputation for full-row sync batches`, async () => { + oracleRoute(`fixed history`) await runTrace({ steps: fullRowBatchTrace, driver: createFullRowBatchTraceDriver(1), @@ -4246,11 +4318,13 @@ describe(`includes recompute oracle`, () => { }) }) - fcTest(`a reinserted parent drops its old shared route`, () => - expectFullRowBatchScenarioMatches(fullRowSharedRoutingSeed), - ) + fcTest(`a reinserted parent drops its old shared route`, () => { + oracleRoute(`fixed history`) + return expectFullRowBatchScenarioMatches(fullRowSharedRoutingSeed) + }) fcTest(`supports repeated optimistic rollbacks in one history`, async () => { + oracleRoute(`fixed history`) await expectScenarioMatches({ depth: 1, history: [ @@ -4301,7 +4375,10 @@ describe(`includes recompute oracle`, () => { )) { fcTest.prop([scenarioArbitrary], options)( `matches naive recomputation after every incremental change [${label}]`, - expectScenarioMatches, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(options)) + return expectScenarioMatches(...args) + }, ) } @@ -4315,7 +4392,10 @@ describe(`includes recompute oracle`, () => { )) { fcTest.prop([distinctMaterializationArbitrary], options)( `matches recomputation for nested scalar materialization [${label}]`, - expectMaterializeScenarioMatches, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(options)) + return expectMaterializeScenarioMatches(...args) + }, ) } @@ -4351,6 +4431,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop(alphaArbitraries, options)( `is unchanged by alpha-renaming, sibling declaration order, or an unrelated sibling [${label}]`, async (rootRows, childRows) => { + oracleRoute(oracleRouteOf(options)) const roots = createControlledCollection( `metamorphic-roots`, rootRows, @@ -4475,6 +4556,7 @@ describe(`includes recompute oracle`, () => { fcTest.prop([confirmedValueArbitrary], options)( `optimistic updates converge to confirmed-only state [${label}]`, async (confirmedValue) => { + oracleRoute(oracleRouteOf(options)) const roots = createControlledCollection(`convergence-roots`, [ { id: 1, group: 1, value: 0, position: 0 }, ]) @@ -4542,13 +4624,15 @@ describe(`includes recompute oracle`, () => { ) } - fcTest(`confirmed child reorder matches recomputation`, () => - expectScenarioMatches(confirmedChildReorderSeed), - ) + fcTest(`confirmed child reorder matches recomputation`, () => { + oracleRoute(`fixed history`) + return expectScenarioMatches(confirmedChildReorderSeed) + }) - fcTest(`shared scalar materialization preserves the deepest row`, () => - expectMaterializeScenarioMatches(sharedMaterializeSeed), - ) + fcTest(`shared scalar materialization preserves the deepest row`, () => { + oracleRoute(`fixed history`) + return expectMaterializeScenarioMatches(sharedMaterializeSeed) + }) }) /** @@ -4727,7 +4811,7 @@ const scopedNoteRows: Array = [ { id: 200, partId: 2, clientId: 1 }, ] -function pinnedScenario( +function fixedScenario( shape: ScopedShape, aliases: ScopedAliases, ): ScopedScenario { @@ -4748,10 +4832,10 @@ function pinnedScenario( * overbroad shadowing check once rejected. Together they reach every optimizer site that once re-minted a * SourceId; the review record maps each site to its killing witness. */ -const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ +const fixedScopeWitnesses: Array<[string, ScopedScenario]> = [ [ `an include reuses the join alias of a joined subquery`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { @@ -4765,7 +4849,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the second join alias`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { ...plainSubquery, joins: `refsThenNotes` }, @@ -4776,7 +4860,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the source alias of an ordered, limited subquery`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { ...plainSubquery, body: `orderedLimit` }, @@ -4787,7 +4871,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the source alias of a DISTINCT subquery`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: { ...plainSubquery, body: `distinct`, subSelect: `fields` }, @@ -4798,7 +4882,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include reuses the source alias of a joined union branch`, - pinnedScenario( + fixedScenario( { topology: `unionBranch`, subquery: { ...plainSubquery, subSelect: `fields` }, @@ -4816,7 +4900,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `an include on a joined union reuses a branch alias`, - pinnedScenario( + fixedScenario( { topology: `unionParent`, includeSource: `refs` }, { activeBranch: `x`, inactiveBranch: `y`, anchor: `a`, include: `x` }, ), @@ -4824,7 +4908,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ [ `an include shadows its parent source alias`, { - ...pinnedScenario( + ...fixedScenario( { topology: `fromSubquery`, subquery: plainSubquery, @@ -4844,7 +4928,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `a nested include source shadows its outer include source`, - pinnedScenario( + fixedScenario( { topology: `fromSubquery`, subquery: plainSubquery, @@ -4862,7 +4946,7 @@ const pinnedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], [ `a join subquery reuses the alias of a collapsed pure wrapper`, - pinnedScenario( + fixedScenario( { topology: `wrapper`, wrapperJoin: `inner` }, { wrapped: `x`, joinSub: `k`, joinSource: `x` }, ), @@ -4874,10 +4958,11 @@ describe(`includes alpha-renaming across lexical scopes`, () => { `an include that reuses a joined subquery alias keeps the reported rows`, // Issue #1975: only part 1 has a ref for client 1; parts 1 and 2 are // active. - () => - expectScopedAlphaRenamingHolds( + () => { + oracleRoute(`fixed history`) + return expectScopedAlphaRenamingHolds( { - ...pinnedScopeWitnesses[0]![1], + ...fixedScopeWitnesses[0]![1], parts: [ { id: 1, active: true }, { id: 2, active: true }, @@ -4889,14 +4974,16 @@ describe(`includes alpha-renaming across lexical scopes`, () => { notes: [], }, [{ id: 1, active: true, members: [{ id: 10, clientId: 1 }] }], - ), + ) + }, ) - for (const [name, scenario] of pinnedScopeWitnesses) { + for (const [name, scenario] of fixedScopeWitnesses) { for (const mode of [`eager`, `onDemand`] as const) { - fcTest(`${name} [${mode}]`, () => - expectScopedAlphaRenamingHolds({ ...scenario, mode }), - ) + fcTest(`${name} [${mode}]`, () => { + oracleRoute(`case`) + return expectScopedAlphaRenamingHolds({ ...scenario, mode }) + }) } } @@ -4907,14 +4994,17 @@ describe(`includes alpha-renaming across lexical scopes`, () => { )) { fcTest.prop([scopedScenarioArbitrary], options)( `is unchanged when lexical scopes reuse alias names [${label}]`, - (scenario) => expectScopedAlphaRenamingHolds(scenario), + (scenario) => { + oracleRoute(oracleRouteOf(options)) + return expectScopedAlphaRenamingHolds(scenario) + }, // Each run builds four live queries; scale with the run budget. Math.max(5_000, oracleRuns(80) * 50), ) } }) -// A real active-bucket receiving lane extends this owner's numeric route +// A real active-bucket receiving path extends this owner's numeric route // domain with native route-string collisions. The same full-tree recompute // judges inline arrays and Collection facades after each source transaction. // The fixture proves only the collision premise; it computes no expected rows. @@ -4948,6 +5038,7 @@ for (const campaign of generatedCampaigns( `includes.bucket-collisions`, DISTINCT_REACH_STATES, ) + oracleRoute(oracleRouteOf(campaign.options)) await fc.assert( fc.asyncProperty(distinctHistory, async (worlds) => { reach.beginHistory() diff --git a/packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts b/packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts index b2b2ae8164..6874a75f02 100644 --- a/packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts +++ b/packages/db/tests/query/includes-outer-join-route-work-oracle.test.ts @@ -9,6 +9,7 @@ import { BasicIndex } from '../../src/indexes/basic-index.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { flushPromises } from '../utils.js' import { declareOracle } from '../oracle-declaration.js' +import { oracleRoute } from '../oracle-config.js' import { createScopedSource } from './includes-scope-identity-oracle.js' import type { crossJoinParentRoutes } from '../../src/query/compiler/parent-routes.js' @@ -111,12 +112,21 @@ const model = ( ), ) -declareOracle({ model: [model] }) +declareOracle({ + owns: [ + 'src/collection/change-events.ts#createFilterFunctionFromExpression', + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + model: [model], +}) describe(`direct outer-join route work`, () => { for (const kind of [`right`, `full`] as const) { for (const mode of [`eager`, `onDemand`] as const) { test(`${kind} main correlation with ${mode} sources avoids work from unrelated joined rows`, async () => { + oracleRoute(`case`) const parentRows: Array = [{ id: 1 }, { id: 2 }] const mainRows: Array
= [ { id: 10, parentId: 1, anchorId: 7 }, diff --git a/packages/db/tests/query/includes-publication-oracle.test.ts b/packages/db/tests/query/includes-publication-oracle.test.ts index a4f8ad5b6b..19acd24c1b 100644 --- a/packages/db/tests/query/includes-publication-oracle.test.ts +++ b/packages/db/tests/query/includes-publication-oracle.test.ts @@ -11,7 +11,12 @@ import { BasicIndex } from '../../src/indexes/basic-index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { createEffect } from '../../src/query/effect.js' import { runTrace } from '../trace-runner.js' -import { oraclePropertyOptions, readOracleRunConfig } from '../oracle-config.js' +import { + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + readOracleRunConfig, +} from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { flushPromises, withExpectedRejection } from '../utils.js' @@ -582,6 +587,13 @@ function expectedHeldPublicationRows( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/indexes/btree-index.ts', + 'src/query/effect.ts#createEffect', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [expectPublicationMatches], model: [recomputeRows, expectedHeldPublicationRows], }) @@ -597,7 +609,7 @@ function publicationCampaigns(baseRuns: number, property: string) { if (replay.replayPath !== undefined && replay.replayProperty !== property) return [] const options = oraclePropertyOptions(baseRuns, property) - if (replay.replaySeed !== undefined) return [{ name: `replay`, options }] + if (replay.campaignSeed !== undefined) return [{ name: `replay`, options }] return [ { name: `fixed seed ${fixedPublicationSeed}`, @@ -624,6 +636,7 @@ describe(`layered-query publication oracle`, () => { for (const q1Shape of q1Shapes) { for (const q2Shape of q2Shapes) { it(`observes every plain write before yielding through ${q1Shape}/${q2Shape}`, async () => { + oracleRoute(`case`) const actions: Array = [ { type: `parentScalar`, value: 7 }, { type: `childScalar`, value: 8 }, @@ -664,6 +677,7 @@ describe(`layered-query publication oracle`, () => { }) it(`rejects a transient layered tear through ${q1Shape}/${q2Shape}`, async () => { + oracleRoute(`case`) const run = (observeInsideAction: boolean) => { let transient = false const driver = createPublicationDriver(q1Shape, q2Shape) @@ -702,6 +716,7 @@ describe(`layered-query publication oracle`, () => { }) it(`rejects a callback-only tear before reads recover through ${q1Shape}/${q2Shape}`, async () => { + oracleRoute(`case`) const driver = createPublicationDriver(q1Shape, q2Shape) let reached = 0 const context = await driver.setup() @@ -744,6 +759,7 @@ describe(`layered-query publication oracle`, () => { }) it(`checks both sides of confirmation through ${q1Shape}/${q2Shape}`, async () => { + oracleRoute(`case`) for (const fault of [`none`, `optimistic`, `confirmed`] as const) { const driver = createPublicationDriver(q1Shape, q2Shape) let phase: `initial` | `optimistic` | `confirmed` = `initial` @@ -792,6 +808,7 @@ describe(`layered-query publication oracle`, () => { fcTest.prop([changedValueArbitrary], campaign.options)( `publishes parent scalar updates through a ${q1Shape} Q1 and ${q2Shape} Q2 (${campaign.name})`, async (value) => { + oracleRoute(oracleRouteOf(campaign.options)) await expectPublicationMatches( { type: `parentScalar`, value }, q1Shape, @@ -811,6 +828,7 @@ describe(`layered-query publication oracle`, () => { )( `recovers a ${q1Shape} Q1 and ${q2Shape} Q2 after a child update (${campaign.name})`, async (parentValue, childValue) => { + oracleRoute(oracleRouteOf(campaign.options)) await expectPublicationMatches( { type: `parentThenChild`, parentValue, childValue }, q1Shape, @@ -827,6 +845,7 @@ describe(`layered-query publication oracle`, () => { fcTest.prop([changedValueArbitrary], campaign.options)( `publishes optimistic state before and after confirmation through a ${q1Shape} Q1 and ${q2Shape} Q2 (${campaign.name})`, async (value) => { + oracleRoute(oracleRouteOf(campaign.options)) await expectPublicationMatches( { type: `optimisticConfirm`, value }, q1Shape, @@ -844,6 +863,7 @@ describe(`layered-query publication oracle`, () => { ])( `rejects missing $phase callbacks at the public checkpoint`, async ({ cut, checkpoint: expectedCheckpoint }) => { + oracleRoute(`case`) const driver = createPublicationDriver(`direct`, `passThrough`) let reachedCuts = 0 let previousCallbackCount = 0 @@ -880,6 +900,7 @@ describe(`layered-query publication oracle`, () => { fcTest.prop([changedChildValueArbitrary], campaign.options)( `publishes child-only scalar updates through both layers (${campaign.name})`, async (value) => { + oracleRoute(oracleRouteOf(campaign.options)) await expectPublicationMatches({ type: `childScalar`, value }) }, ) @@ -892,6 +913,7 @@ describe(`layered-query publication oracle`, () => { fcTest.prop([fc.constantFrom(20, 30)], campaign.options)( `compares route transitions at both query layers (${campaign.name})`, async (group) => { + oracleRoute(oracleRouteOf(campaign.options)) await expectPublicationMatches({ type: `parentRoute`, group }) }, ) @@ -912,6 +934,7 @@ describe(`layered-query publication oracle`, () => { )( `compares atomic parent replacements at both query layers (${campaign.name})`, async (row) => { + oracleRoute(oracleRouteOf(campaign.options)) await expectPublicationMatches({ type: `atomicReplace`, ...row }) }, ) @@ -924,6 +947,7 @@ describe(`layered-query publication oracle`, () => { fcTest.prop([changedValueArbitrary], campaign.options)( `publishes restored state after optimistic rollback (${campaign.name})`, async (value) => { + oracleRoute(oracleRouteOf(campaign.options)) await expectPublicationMatches({ type: `optimisticRollback`, value, @@ -950,6 +974,7 @@ describe(`held include publication`, () => { ])( `compares the final $intermediate to $final row with the last public snapshot`, async ({ intermediate, final }) => { + oracleRoute(`case`) type Row = { id: number; rank: number; value: string } const sourceRows = new Map([ [1, { id: 1, rank: 1, value: `A` }], diff --git a/packages/db/tests/query/includes-query-shape-oracle.test.ts b/packages/db/tests/query/includes-query-shape-oracle.test.ts index d5507b0e8c..cdc571d8f9 100644 --- a/packages/db/tests/query/includes-query-shape-oracle.test.ts +++ b/packages/db/tests/query/includes-query-shape-oracle.test.ts @@ -7,7 +7,13 @@ import { } from '../../src/query/index.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { runTrace } from '../trace-runner.js' -import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { cleanupQuery } from './includes-query-shape-support.js' @@ -30,7 +36,7 @@ import type { TraceDriver, TraceProjection } from '../trace-runner.js' * law stronger. * * Stable campaigns preserve the histories that exposed these distinctions. - * Fresh random campaigns vary their value domains. Pinned examples cover route + * Fresh random campaigns vary their value domains. Fixed histories cover route * movement and repeated retirement because those laws need ordered histories, * not more random scalar values. * @@ -447,6 +453,11 @@ const nullableProjection: TraceProjection< * each computed from the driver's own Maps. */ declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], model: [ expectedMultiplicityRows, expectedCorrelationRows, @@ -455,7 +466,7 @@ declareOracle({ }) function campaigns(fixedSeed: number, property: string) { - // Grammar controls: the pinned 1-child case reconstructs final retirement, + // Grammar controls: the fixed 1-child history reconstructs final retirement, // while counts 2–5 keep a surviving contributor. Equal route keys are the // control; distinct order and part IDs expose the wrong-alias result. // Removing null or route updates loses empty-value or reactivation behavior. @@ -464,7 +475,7 @@ function campaigns(fixedSeed: number, property: string) { return [ { name: `fixed`, - options: { numRuns: oracleRuns(12), seed: fixedSeed }, + options: oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(12) }), }, { name: `random or replayed`, @@ -478,6 +489,7 @@ describe(`includes query-shape recompute oracle`, () => { fcTest.prop([fc.integer({ min: 2, max: 5 })], campaign.options)( `deleting one joined contributor preserves remaining multiplicity (${campaign.name})`, async (childCount) => { + oracleRoute(oracleRouteOf(campaign.options)) await runTrace({ steps: [1], driver: createMultiplicityDriver(childCount), @@ -489,17 +501,20 @@ describe(`includes query-shape recompute oracle`, () => { fcTest( `matches recomputation when the final joined contributor is deleted`, - () => - runTrace({ + () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [1], driver: createMultiplicityDriver(1), projection: multiplicityProjection, - }), + }) + }, ) fcTest( `rejects a premature parent removal after one contributor leaves`, async () => { + oracleRoute(`calibration`) await expect( runTrace({ steps: [1], @@ -528,6 +543,7 @@ describe(`includes query-shape recompute oracle`, () => { )( `materialization follows correlation through a joined alias (${campaign.name})`, async ({ correlationId, productionId }) => { + oracleRoute(oracleRouteOf(campaign.options)) await runTrace({ steps: [], driver: createCorrelationDriver( @@ -544,18 +560,21 @@ describe(`includes query-shape recompute oracle`, () => { fcTest( `matches recomputation when materialization correlates through its source alias`, - () => - runTrace({ + () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [], driver: createCorrelationDriver(`source`, 1, 101), projection: correlationProjection, - }), + }) + }, ) fcTest.each([`source`, `joined`] as const)( `keeps %s correlation distinct from join identity through route moves`, - (target) => - runTrace({ + (target) => { + oracleRoute(`case`) + return runTrace({ // Part 1 is the joined correlation. Part 7 is the source correlation. // Moving order.partId changes only the joined query, then restores it. steps: [ @@ -564,12 +583,14 @@ describe(`includes query-shape recompute oracle`, () => { ], driver: createCorrelationDriver(target, 1, 101, 7), projection: correlationProjection, - }), + }) + }, ) fcTest( `rejects the wrong correlation alias at the initial checkpoint`, async () => { + oracleRoute(`calibration`) await expect( runTrace({ steps: [], @@ -588,6 +609,7 @@ describe(`includes query-shape recompute oracle`, () => { fcTest.prop([fc.integer({ min: 1, max: 100 })], campaign.options)( `findOne maps a null correlation key to undefined (${campaign.name})`, async (postId) => { + oracleRoute(oracleRouteOf(campaign.options)) await runTrace({ steps: [], driver: createNullableDriver([], [{ id: postId, authorId: null }]), @@ -599,31 +621,36 @@ describe(`includes query-shape recompute oracle`, () => { fcTest( `matches recomputation for an unmatched non-null correlation key`, - () => - runTrace({ + () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [], driver: createNullableDriver([], [{ id: 1, authorId: 999 }]), projection: nullableProjection, - }), + }) + }, ) fcTest( `matches recomputation when an existing correlation key becomes null`, - () => - runTrace({ + () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [{ id: 1, authorId: null }], driver: createNullableDriver( [{ id: 1, name: `Ada` }], [{ id: 1, authorId: 1 }], ), projection: nullableProjection, - }), + }) + }, ) fcTest( `reactivates a singleton after null and repeated route retirement`, - () => - runTrace({ + () => { + oracleRoute(`fixed history`) + return runTrace({ steps: [ { id: 1, authorId: 1 }, { id: 1, authorId: null }, @@ -634,12 +661,14 @@ describe(`includes query-shape recompute oracle`, () => { [{ id: 1, authorId: null }], ), projection: nullableProjection, - }), + }) + }, ) fcTest( `rejects a stale empty singleton at its reactivation checkpoint`, async () => { + oracleRoute(`calibration`) await expect( runTrace({ steps: [{ id: 1, authorId: 1 }], diff --git a/packages/db/tests/query/includes-route-grammar-oracle.ts b/packages/db/tests/query/includes-route-grammar-oracle.ts index 9142339884..43c3664f6f 100644 --- a/packages/db/tests/query/includes-route-grammar-oracle.ts +++ b/packages/db/tests/query/includes-route-grammar-oracle.ts @@ -37,7 +37,7 @@ import type { ChildRow, RootRow } from './includes-structural-support.js' * the expected result. * * The trace runner checks each intermediate cut that the contract exposes. - * Focused pinned histories preserve small boundary cases. Random or replayed + * Focused fixed histories preserve small boundary cases. Random or replayed * campaigns explore longer action sequences and wider route values. Other * include suites own Collection facades, demand lifetime, functional callbacks, * and publication coherence. diff --git a/packages/db/tests/query/includes-space-oracle.test.ts b/packages/db/tests/query/includes-space-oracle.test.ts index 5c6add893d..fdc93b9459 100644 --- a/packages/db/tests/query/includes-space-oracle.test.ts +++ b/packages/db/tests/query/includes-space-oracle.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { declareOracle } from '../oracle-declaration.js' +import { oracleRoute } from '../oracle-config.js' import { countEntries, withSpaceFixture } from './includes-space-helpers.js' import type { AdapterState } from './includes-space-helpers.js' @@ -34,12 +35,22 @@ function expectedFacadeCount(rootCount: number): number { return rootCount + rootCount * 2 + rootCount * 2 * 5 } -declareOracle({ model: [expectedFacadeCount] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/local-only.ts#localOnlyCollectionOptions', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/live/bucket-facade-adapter.ts', + ], + model: [expectedFacadeCount], +}) describe(`nested Collection materialization space oracle`, () => { it.each([0, 1, 20])( `constructs exactly one facade per reachable bucket for %i roots`, async (rootCount) => { + oracleRoute(`case`) await withSpaceFixture(rootCount, async (fixture, entries) => { await fixture.live.preload() diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index cb960e7dff..680cd0c477 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -15,8 +15,11 @@ import { } from '../../src/query/index.js' import { runTrace } from '../trace-runner.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -83,7 +86,7 @@ import type { * imply one physical acquisition or one provider transport. The fast/late * phase is a fixture clock around source startup, not an ordered query window. * fast-check schedules current and obsolete completions in new orders, while - * fixed scheduler orders pin both directions. + * fixed scheduler orders cover both directions. * * The production drivers use real Collections, compiled includes, applied * receipts, release callbacks, replay barriers, and source writes. They observe @@ -486,7 +489,7 @@ function expectReleaseReentryObservation( // "the first query loads immediately (on-demand)" (TanStack DB 0.5 release // post). An eager source serves no subset demand, so its Collection readiness // is the only evidence. The architecture's initial-readiness law states this -// rule; this lane is its executable owner. +// rule; this oracle is its oracle owner. // // The law has three kinds of consumer. A live-query Collection becomes ready, // and its preload settles, at initial-query readiness. A one-shot read resolves @@ -529,7 +532,7 @@ function expectReleaseReentryObservation( // The live-query shapes put the lagging source at a direct root, a // single-result root, an ordered root window, a correlated include child, // the lazy side of a join, an eager root that sets `syncMode: 'eager'`, and -// an eager root that leaves `syncMode` at its default. The Effect lane uses +// an eager root that leaves `syncMode` at its default. The Effect production path uses // the direct, lazy-join, and both eager-root shapes; Effects over an ordered // window or an include are not covered here. The eager roots are the nearby // witnesses: a design that ignores source readiness entirely completes them @@ -552,7 +555,7 @@ function expectReleaseReentryObservation( // and controlled source writes. Each step is followed by a timer turn. Each // checkpoint compares the consumer's observation and the number of // acquisitions; the ordered shape records only that it acquired. A rejected acquisition, source error, and later demand changes -// are outside this lane; Models A to C, the Effect disposal oracle, and the +// are outside this grammar; Models A to C, the Effect disposal oracle, and the // subscription lifecycle oracle own them. The Electric live-query tests hold // the receiving witnesses for a real progressive source and for an on-demand // source that is ready before its subset loads. @@ -1276,14 +1279,14 @@ 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. +// Each generated campaign uses one grammar and one check in both campaigns. A +// seed-and-path replay selects only its requested random campaign. function temporalCampaigns(property: string, fixedSeed: number) { return [ { label: `fixed seed ${fixedSeed}`, test: oracleReplayTest(fcTest), - options: { numRuns: oracleRuns(20), seed: fixedSeed }, + options: oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(20) }), }, { label: `random or replayed seed`, @@ -1294,6 +1297,14 @@ function temporalCampaigns(property: string, fixedSeed: number) { } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/query/effect.ts#createEffect', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/live/subset-demand-controller.ts', + 'src/query/query-once.ts#queryOnce', + ], check: [ expectReadinessMatches, expectScheduledDemandObservation, @@ -1309,10 +1320,13 @@ declareOracle({ }) describe(`includes temporal oracle`, () => { - it(`an empty outer does not wait for an undemanded child`, () => - expectReadinessMatches([])) + it(`an empty outer does not wait for an undemanded child`, () => { + oracleRoute(`fixed history`) + return expectReadinessMatches([]) + }) it(`loads a demanded child before becoming ready`, async () => { + oracleRoute(`fixed history`) await expectReadinessMatches([ { id: 1, authorId: `selected`, title: `one` }, { id: 2, authorId: `selected`, title: `two` }, @@ -1333,17 +1347,29 @@ describe(`includes temporal oracle`, () => { )) { campaign.test.prop([demandScheduler], campaign.options)( `acquisition settlements preserve current demand with ${campaign.label}`, - expectScheduledAcquisitionsRespectCurrentDemand, + ( + ...args: Parameters< + typeof expectScheduledAcquisitionsRespectCurrentDemand + > + ) => { + oracleRoute(oracleRouteOf(campaign.options)) + return expectScheduledAcquisitionsRespectCurrentDemand(...args) + }, ) } it.each([{ order: [1, 2] }, { order: [2, 1] }])( `observes every completion in fixed scheduler order $order`, - ({ order }) => - expectScheduledAcquisitionsRespectCurrentDemand(fc.schedulerFor(order)), + ({ order }) => { + oracleRoute(`case`) + return expectScheduledAcquisitionsRespectCurrentDemand( + fc.schedulerFor(order), + ) + }, ) it(`rejects an obsolete child row at the first scheduled checkpoint`, () => { + oracleRoute(`calibration`) expect(() => expectScheduledDemandObservation( { @@ -1356,10 +1382,13 @@ describe(`includes temporal oracle`, () => { ).toThrowError(expect.objectContaining({ name: `AssertionError` })) }) - it(`reactivated demand retries after its prior release fails`, () => - expectDemandReactivationRetriesAfterReleaseFailure([1])) + it(`reactivated demand retries after its prior release fails`, () => { + oracleRoute(`fixed history`) + return expectDemandReactivationRetriesAfterReleaseFailure([1]) + }) it(`rejects a failed release that suppresses reacquisition`, () => { + oracleRoute(`calibration`) expect(() => expectReleaseReentryObservation( { @@ -1392,7 +1421,14 @@ describe(`includes temporal oracle`, () => { )) { campaign.test.prop([releaseKeys], campaign.options)( `failed release never suppresses a later demand incarnation with ${campaign.label}`, - expectDemandReactivationRetriesAfterReleaseFailure, + ( + ...args: Parameters< + typeof expectDemandReactivationRetriesAfterReleaseFailure + > + ) => { + oracleRoute(oracleRouteOf(campaign.options)) + return expectDemandReactivationRetriesAfterReleaseFailure(...args) + }, ) } @@ -1425,8 +1461,12 @@ describe(`includes temporal oracle`, () => { })), ), ) - it.each(cells)(`$consumer $shape: $history`, ({ consumer, shape, steps }) => - expectLaggingSourceTraceMatches(consumer, shape, steps), + it.each(cells)( + `$consumer $shape: $history`, + ({ consumer, shape, steps }) => { + oracleRoute(`case`) + return expectLaggingSourceTraceMatches(consumer, shape, steps) + }, ) }) @@ -1450,6 +1490,7 @@ describe(`includes temporal oracle`, () => { campaign.test.prop([partialBodies], campaign.options)( `publishes each partial child prefix before the acquisition promise fulfills with ${campaign.label}`, async (bodies) => { + oracleRoute(oracleRouteOf(campaign.options)) for (const form of [`array`, `materialized`] as const) { const parents = createMutablePosts([ { id: 1, authorId: `selected`, title: `Receiving children` }, diff --git a/packages/db/tests/query/includes-work-counter-oracle.test.ts b/packages/db/tests/query/includes-work-counter-oracle.test.ts index d353719d1f..541d6b5a25 100644 --- a/packages/db/tests/query/includes-work-counter-oracle.test.ts +++ b/packages/db/tests/query/includes-work-counter-oracle.test.ts @@ -1,8 +1,11 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { beforeAll, describe, expect, it, vi } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { createOracleReach } from '../oracle-reach.js' @@ -63,8 +66,8 @@ import type { * The input grammar varies an integer filler count from 1 through 24. Zero is * the ablated baseline; negative and fractional counts are invalid fixture * sizes. Joined queries vary unrelated links and join targets separately. - * The join-free query grows every source together. Counts 1, 2, and 3 pin the - * smallest valid boundary, 24 pins the upper bound, and fixed and random + * The join-free query grows every source together. Counts 1, 2, and 3 cover the + * smallest valid boundary, 24 covers the upper bound, and fixed and random * campaigns sample the range. * * The boundary conditions matter. Collections preload before they receive @@ -152,7 +155,7 @@ function campaigns(fixedSeed: number, property: string, baseRuns = 6) { return [ { name: `fixed`, - options: { numRuns: oracleRuns(baseRuns), seed: fixedSeed }, + options: oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(baseRuns) }), }, { name: `random or replayed`, @@ -172,38 +175,58 @@ describe(`includes deterministic work-counter oracle`, () => { }) it.each([1, 2, 3])( - `pins the work bound at the small filler boundary (%i)`, - expectCorrelatedJoinWorkBound, + `checks the work bound at the small filler boundary (%i)`, + (...args: Parameters) => { + oracleRoute(`case`) + return expectCorrelatedJoinWorkBound(...args) + }, ) - it(`pins the joined-link filler range endpoint`, () => - expectCorrelatedJoinWorkBound(24)) + it(`checks the joined-link filler range endpoint`, () => { + oracleRoute(`fixed history`) + return expectCorrelatedJoinWorkBound(24) + }) it.each([1, 24])( - `pins join-target growth at filler count %i`, - expectJoinTargetWorkBound, + `checks join-target growth at filler count %i`, + (...args: Parameters) => { + oracleRoute(`case`) + return expectJoinTargetWorkBound(...args) + }, ) it.each([1, 24])( - `pins join-free growth at filler count %i`, - expectJoinFreeWorkBound, + `checks join-free growth at filler count %i`, + (...args: Parameters) => { + oracleRoute(`case`) + return expectJoinFreeWorkBound(...args) + }, ) for (const campaign of campaigns(1709, `includes-work.correlated-links`)) { fcTest.prop([fc.integer({ min: 1, max: 24 })], campaign.options)( `a join preserves correlated source pushdown (${campaign.name})`, - expectCorrelatedJoinWorkBound, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(campaign.options)) + return expectCorrelatedJoinWorkBound(...args) + }, ) } for (const campaign of campaigns(170_900, `includes-work.join-targets`)) { fcTest.prop([fc.integer({ min: 1, max: 24 })], campaign.options)( `indexed join-target growth keeps source work flat (${campaign.name})`, - expectJoinTargetWorkBound, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(campaign.options)) + return expectJoinTargetWorkBound(...args) + }, ) } for (const campaign of campaigns(17_090, `includes-work.join-free`)) { fcTest.prop([fc.integer({ min: 1, max: 24 })], campaign.options)( `join-free correlated includes keep source work flat (${campaign.name})`, - expectJoinFreeWorkBound, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(campaign.options)) + return expectJoinFreeWorkBound(...args) + }, ) } }) @@ -289,8 +312,8 @@ describe(`includes deterministic work-counter oracle`, () => { * Collection delivers more change batches to the query than the initial load * at preload, or than the step's own parent write. The generated campaigns * skip only the work bound at refill checkpoints and still check the rows. - * Replays pin every known trace and allow exactly the one extra child row - * each shows, and pinned forward moves allow none. Each generated campaign + * Fixed regressions cover every known trace and allow exactly the one extra child row + * each shows, and fixed forward moves allow none. Each generated campaign * counts the forward moves its own runs check strictly and requires some. * * ## Histories @@ -1060,6 +1083,12 @@ async function checkWindowedIncludeWork(testCase: WindowCase): Promise { // checks compare each scaled fixture with its minimal baseline run. The grammar // is the windowed history generator, which the reconstruction test samples. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/local-only.ts#localOnlyCollectionOptions', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [ checkWindowedIncludeWork, expectCorrelatedJoinWorkBound, @@ -1074,12 +1103,12 @@ declareOracle({ // reconstruction check below counts them. const WINDOW_RUNS = 60 -/** A pinned case: its strict forward-move tally is not reach evidence. */ +/** A fixed history: its strict forward-move tally is not reach evidence. */ async function expectWindowedIncludeWork(testCase: WindowCase): Promise { await checkWindowedIncludeWork(testCase) } -const pinnedWindowCase: WindowCase = { +const fixedWindowCase: WindowCase = { parents: 8, groups: 8, copies: [], @@ -1098,9 +1127,10 @@ const pinnedWindowCase: WindowCase = { describe(`includes work under an ordered parent window`, () => { it.each([false, true])( `loads children only for a late page's parents (distinct=%s)`, - (distinct) => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + (distinct) => { + oracleRoute(`case`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, parents: 40, groups: 40, childrenPerGroup: 3, @@ -1108,22 +1138,26 @@ describe(`includes work under an ordered parent window`, () => { distinct, offset: 20, history: [{ type: `move`, offset: 10, limit: 3 }], - }), + }) + }, ) it.each([`ordered`, `limited`, `limited-nested`] as const)( `moves the parent window, not the %s child's`, - (childForm) => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + (childForm) => { + oracleRoute(`case`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, childForm, history: [{ type: `move`, offset: 3, limit: 2 }], - }), + }) + }, ) - it(`collapses parents whose selected values match under distinct`, () => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + it(`collapses parents whose selected values match under distinct`, () => { + oracleRoute(`fixed history`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, copies: [ { pick: 1, group: true }, { pick: 2, group: false }, @@ -1131,22 +1165,26 @@ describe(`includes work under an ordered parent window`, () => { distinct: true, limit: 3, history: [{ type: `insert-parent`, copy: 3 }], - })) + }) + }) - it(`creates no static child demand from a static parent filter`, () => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + it(`creates no static child demand from a static parent filter`, () => { + oracleRoute(`fixed history`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, childSource: `on-demand`, parentFilter: [0, 1, 2, 3, 4], offset: 3, limit: 1, - })) + }) + }) // On demand, a group's children arrive only through a load, so a fresh // parent past the narrowed window must not load them. - it(`stops routing rows that setWindow drops from an orderBy-only parent`, () => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + it(`stops routing rows that setWindow drops from an orderBy-only parent`, () => { + oracleRoute(`fixed history`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, childrenPerGroup: 3, childSource: `on-demand`, parentForm: `orderOnly`, @@ -1154,47 +1192,55 @@ describe(`includes work under an ordered parent window`, () => { { type: `move`, offset: 0, limit: 2 }, { type: `insert-parent`, copy: null }, ], - })) + }) + }) - it(`loads one page of children for a findOne parent`, () => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + it(`loads one page of children for a findOne parent`, () => { + oracleRoute(`fixed history`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, childForm: `findOne`, parentForm: `findOne`, offset: 5, - })) + }) + }) // A window requested before start can drop rows a static filter names, // so static child demand must not load their children. - it(`creates no static child demand under a window requested before start`, () => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + it(`creates no static child demand under a window requested before start`, () => { + oracleRoute(`fixed history`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, childForm: `ordered`, childSource: `on-demand`, parentForm: `orderOnly`, parentFilter: [0, 1, 2, 3, 4, 5], startWindow: { offset: 0, limit: 1 }, - })) + }) + }) // A child's static filter names a child whose parent the window drops, so // its grandchild demand must wait for the window too. - it(`creates no static grandchild demand below a windowed parent`, () => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + it(`creates no static grandchild demand below a windowed parent`, () => { + oracleRoute(`fixed history`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, parents: 10, groups: 10, childrenPerGroup: 1, childForm: `nested`, limit: 1, childFilter: [5], - })) + }) + }) // A window requested before start that moves a findOne past its one named // correlation value drops that row, so its children must not load; one at // offset 0 drops nothing, so its static demand still starts at once. - it(`creates no static child demand for a findOne moved past its named value before start`, () => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + it(`creates no static child demand for a findOne moved past its named value before start`, () => { + oracleRoute(`fixed history`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, parents: 21, groups: 11, childrenPerGroup: 1, @@ -1202,7 +1248,8 @@ describe(`includes work under an ordered parent window`, () => { findOneOffset: false, parentFilter: [10], startWindow: { offset: 1, limit: 1 }, - })) + }) + }) // Forward moves load more parent rows, so the generated campaigns cannot // check them strictly; these do, with no overshoot allowed. @@ -1213,9 +1260,10 @@ describe(`includes work under an ordered parent window`, () => { [`on-demand`, true], ] as const)( `moves the window forward strictly (%s children, distinct=%s)`, - (childSource, distinct) => - expectWindowedIncludeWork({ - ...pinnedWindowCase, + (childSource, distinct) => { + oracleRoute(`case`) + return expectWindowedIncludeWork({ + ...fixedWindowCase, parents: 40, groups: 40, childrenPerGroup: 2, @@ -1226,7 +1274,8 @@ describe(`includes work under an ordered parent window`, () => { { type: `move`, offset: 30, limit: 2 }, ], overshoot: 0, - }), + }) + }, ) // Replays of transient top-K rows (#2151). Each shows the correct @@ -1238,7 +1287,7 @@ describe(`includes work under an ordered parent window`, () => { [ `a desc held row moved to the lowest rank`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 24, groups: 12, childrenPerGroup: 1, @@ -1251,7 +1300,7 @@ describe(`includes work under an ordered parent window`, () => { [ `an asc held row moved to the highest rank`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 36, groups: 18, childrenPerGroup: 1, @@ -1263,7 +1312,7 @@ describe(`includes work under an ordered parent window`, () => { [ `a tie at the window edge during preload (late page)`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 38, groups: 19, copies: [{ pick: 735_689_178, group: false }], @@ -1276,7 +1325,7 @@ describe(`includes work under an ordered parent window`, () => { [ `ties at the window edge during preload (two rows)`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 33, groups: 17, copies: [ @@ -1294,7 +1343,7 @@ describe(`includes work under an ordered parent window`, () => { [ `ties at the window edge during preload (first row)`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 11, groups: 6, copies: [ @@ -1311,7 +1360,7 @@ describe(`includes work under an ordered parent window`, () => { [ `a window move while the loader refills`, { - ...pinnedWindowCase, + ...fixedWindowCase, parents: 33, groups: 17, childrenPerGroup: 1, @@ -1325,15 +1374,17 @@ describe(`includes work under an ordered parent window`, () => { }, ], ] - it.each(transient)(`bounds a transient top-K row: %s`, (_, testCase) => - expectWindowedIncludeWork({ ...testCase, overshoot: 1 }), - ) + it.each(transient)(`bounds a transient top-K row: %s`, (_, testCase) => { + oracleRoute(`case`) + return expectWindowedIncludeWork({ ...testCase, overshoot: 1 }) + }) // The same replays fail when no overshoot is allowed, so each one reaches // a transient row and the loader-activity observation sees it. it.each(transient)( `reaches a transient top-K row: %s`, async (_, testCase) => { + oracleRoute(`calibration`) await expect( checkWindowedIncludeWork({ ...testCase, overshoot: 0 }), ).rejects.toThrow(/child rows loaded by/) @@ -1342,11 +1393,12 @@ describe(`includes work under an ordered parent window`, () => { // Reconstruction: the fixed campaign's executed cases reach each premise. it(`reaches every window dimension in the executed fixed cases`, () => { + oracleRoute(`fixed seed`) const cases = fc - .sample(fc.tuple(windowCase), { - numRuns: oracleRuns(WINDOW_RUNS), - seed: 2137, - }) + .sample( + fc.tuple(windowCase), + oracleFixedSeed(2137, { numRuns: oracleRuns(WINDOW_RUNS) }), + ) .map(([testCase]) => testCase) const count = (premise: (testCase: WindowCase) => boolean) => cases.filter(premise).length @@ -1397,6 +1449,7 @@ describe(`includes work under an ordered parent window`, () => { `a parent window bounds child loading (${campaign.name})`, async () => { let strictForwardMoves = 0 + oracleRoute(oracleRouteOf(campaign.options)) await fc.assert( fc.asyncProperty(windowCase, async (testCase) => { strictForwardMoves += await checkWindowedIncludeWork(testCase) @@ -1710,6 +1763,7 @@ const payloadExamples: Array<[number]> = [[0], [1], [20], [40]] it.each(payloadExamples)( `rejects a source payload mutated in place (%i blocks)`, (blockCount) => { + oracleRoute(`calibration`) const document = expectedPayloadDocument(blockCount, 0) document.tick = -1 expect(() => @@ -1721,11 +1775,10 @@ it.each(payloadExamples)( for (const campaign of [ { name: `fixed`, - options: { + options: oracleFixedSeed(2355, { numRuns: oracleRuns(8) + payloadExamples.length, - seed: 2355, examples: payloadExamples, - }, + }), }, { name: `random or replayed`, @@ -1740,6 +1793,11 @@ for (const campaign of [ : oracleReplayTest(fcTest, payloadProperty) register.prop([fc.integer({ min: 0, max: 40 })], campaign.options)( `nested source payloads need no metadata descriptor scans (${campaign.name})`, - expectPayloadWork, + (blockCount) => { + // The options are built when the file loads, so each run registers its + // campaign's route. + oracleRoute(oracleRouteOf(campaign.options)) + return expectPayloadWork(blockCount) + }, ) } diff --git a/packages/db/tests/query/index-path-collision-oracle.test.ts b/packages/db/tests/query/index-path-collision-oracle.test.ts index b3269eb668..623fc90bc2 100644 --- a/packages/db/tests/query/index-path-collision-oracle.test.ts +++ b/packages/db/tests/query/index-path-collision-oracle.test.ts @@ -43,6 +43,7 @@ import { PropRef } from '../../src/query/ir.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' import { mockSyncCollectionOptions } from '../utils.js' import { declareOracle } from '../oracle-declaration.js' +import { oracleRoute } from '../oracle-config.js' import { checkWithCleanup } from './index-path-collision-support.js' import type { Release } from './index-path-collision-support.js' import type { BasicExpression } from '../../src/query/ir.js' @@ -105,6 +106,11 @@ function modelSelectedOrder(): Array { const expectedKeys = modelKeys((row) => row[`a.b`] > 5 && row.a.b < 20) declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], model: [modelKeys, modelSelectedOrder], }) @@ -138,6 +144,7 @@ const cases: Array<{ it.each(cases)( `keeps dotted scalar and nested predicates distinct: $name`, async ({ name, where, accepts }) => { + oracleRoute(`case`) await checkWithCleanup(async (register) => { const collection = await readyCollection(name, register) const expected = modelKeys(accepts) @@ -159,6 +166,7 @@ it.each(cases)( it.each([false, true])( `preserves distinct fields in a Collection subscription callback (indexed=%s)`, async (indexed) => { + oracleRoute(`case`) await checkWithCleanup(async (register) => { const collection = await readyCollection( `subscription-${indexed}`, @@ -180,6 +188,7 @@ it.each([false, true])( ) it(`keeps selected dotted and nested paths distinct in a public order`, async () => { + oracleRoute(`fixed history`) await checkWithCleanup(async (register) => { const collection = await readyCollection(`selected-order`, register) const result = createLiveQueryCollection({ @@ -206,6 +215,7 @@ it(`keeps selected dotted and nested paths distinct in a public order`, async () it.each([false, true])( `preserves distinct fields in a live-query callback (indexed=%s)`, async (indexed) => { + oracleRoute(`case`) await checkWithCleanup(async (register) => { const collection = await readyCollection( `live-query-${indexed}`, 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 2827748f9f..96e2422a75 100644 --- a/packages/db/tests/query/ir-stable-identity-oracle.test.ts +++ b/packages/db/tests/query/ir-stable-identity-oracle.test.ts @@ -29,7 +29,7 @@ import { getQueryIdentity, } from '../../src/query/ir-stable-identity.js' import { Func, IncludesSubquery, PropRef, Value } from '../../src/query/ir.js' -import { oracleRuns } from '../oracle-config.js' +import { oracleFixedSeed, oracleRoute, oracleRuns } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { createAlphaRenamedIncludesQuery, @@ -234,6 +234,16 @@ const outputProgramArbitrary: fc.Arbitrary = fc.record({ }) declareOracle({ + owns: [ + 'src/collection/index.ts', + 'src/live-query-options.ts#getPreparedLiveQueryIdentity', + 'src/query/compiler/evaluators.ts#compileExpression', + 'src/query/ir-stable-identity.ts#getLoadSubsetDemandKey', + 'src/query/ir-stable-identity.ts#getQueryIdentity', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/live/includes-runtime.ts', + 'src/query/pooled-live-query.ts', + ], check: [expectIdentitySharingMatchesOutcome], model: [interpretOutputProgram, modelOutcome], }) @@ -318,6 +328,7 @@ describe(`semantic expression identity`, () => { equivalentExpressionPairArbitrary, fc.record({ age: fc.integer(), active: fc.boolean() }), ])(`canonical expression grammar preserves semantics`, (pair, sample) => { + oracleRoute(`random seed`) const row = { user: sample } expect(compileExpression(pair.original)(row)).toBe( @@ -331,6 +342,7 @@ describe(`semantic expression identity`, () => { fcTest.prop([referenceSemanticPairArbitrary])( `keeps reference-semantic values distinct across expression and demand identity`, ([first, second]) => { + oracleRoute(`random seed`) const value = new PropRef([`row`, `value`]) const firstPredicate = new Func(`eq`, [value, new Value(first)]) const secondPredicate = new Func(`eq`, [ @@ -356,6 +368,7 @@ describe(`semantic expression identity`, () => { maxLength: 8, }), ])(`treats IN candidates as a set`, (candidates) => { + oracleRoute(`random seed`) const value = new PropRef([`row`, `value`]) const ordered = new Func(`in`, [value, new Value(candidates)]) const reordered = new Func(`in`, [ @@ -395,6 +408,7 @@ describe(`stable QueryIR identity smoke test`, () => { maxLength: 4, }), ])(`does not depend on lexical source aliases`, (aliases) => { + oracleRoute(`random seed`) const [firstUser, firstPost, secondUser, secondPost] = aliases expect( @@ -419,6 +433,7 @@ describe(`stable QueryIR identity smoke test`, () => { fcTest.prop([outputExpressionPairArbitrary])( `equal query identities imply equal projected expression results`, ({ first, second }) => { + oracleRoute(`random seed`) const firstIdentity = getQueryIdentity( createProjectedExpressionQuery(first), ) @@ -438,6 +453,7 @@ describe(`stable QueryIR identity smoke test`, () => { ) it(`preserves signed zero through coalesce and binary conversion through conditional output`, () => { + oracleRoute(`fixed history`) assertOutputProgram( observeOutputProgram({ literals: { kind: `signed-zero` }, @@ -457,18 +473,26 @@ describe(`stable QueryIR identity smoke test`, () => { } }) - fcTest.prop([outputProgramArbitrary], { numRuns: 200, seed: 715031 })( + const outputProgramFixedSeed = oracleFixedSeed(715031, { numRuns: 200 }) + fcTest.prop([outputProgramArbitrary], outputProgramFixedSeed)( `matches output denotation through composed contexts with a fixed seed`, - (program) => assertOutputProgram(observeOutputProgram(program)), + (program) => { + oracleRoute(`fixed seed`) + return assertOutputProgram(observeOutputProgram(program)) + }, ) fcTest.prop([outputProgramArbitrary], { numRuns: oracleRuns(200) })( `matches output denotation through random composed contexts`, - (program) => assertOutputProgram(observeOutputProgram(program)), + (program) => { + oracleRoute(`random seed`) + return assertOutputProgram(observeOutputProgram(program)) + }, ) it.each([`shared-wrong-output`, `constant-identity`] as const)( `rejects ${`%s`} despite mutual agreement`, (fault) => { + oracleRoute(`calibration`) const observations = observeOutputProgram({ literals: { kind: `signed-zero` }, wrappers: [{ kind: `coalesce` }], @@ -492,6 +516,7 @@ describe(`stable QueryIR identity smoke test`, () => { alias !== `kind` && alias !== `profile` && alias !== `userId`, ), ])(`does not bind union-derived output fields to outer aliases`, (alias) => { + oracleRoute(`random seed`) expect(getQueryIdentity(createUnionDerivedNestedQuery(`kind`))).toBe( getQueryIdentity(createUnionDerivedNestedQuery(alias)), ) @@ -509,6 +534,7 @@ describe(`stable QueryIR identity smoke test`, () => { fcTest.prop([referenceSemanticPairArbitrary])( `keeps queries distinct when captured values compare by reference`, ([first, second]) => { + oracleRoute(`random seed`) const firstQuery = createProfileValueQuery(first) const secondQuery = createProfileValueQuery(second) const firstPredicate = firstQuery.where![0] as BasicExpression @@ -637,6 +663,7 @@ describe(`operator-code identity`, () => { ) it(`keeps queries whose operator code changes the compile outcome distinct`, () => { + oracleRoute(`fixed history`) const results = cases.map(({ groupBy, agg, inc }) => { const ir = buildIR(groupBy, agg, inc) const label = `${groupBy} groupBy, ${agg} aggregate, ${inc} include` @@ -809,6 +836,7 @@ describe(`operator-code identity`, () => { }) it(`keeps every operator-code variant with a different compile outcome distinct`, () => { + oracleRoute(`case`) const identities = { ir: (ir: QueryIR) => getQueryIdentity(ir), pooled: (ir: QueryIR) => 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 c7246ebc8b..c18922d6fe 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 @@ -29,11 +29,11 @@ * * Calibration: the fixed pair (`a,b`, `c`) versus (`a`, `b,c`) and the pair * (1, `c`) versus (`1`, `c`) collided under the comma encoding; restoring it - * fails both pinned histories and both campaigns. Plain `JSON.stringify` - * printed `Infinity` and `-Infinity` as `null`; restoring it fails the pinned + * fails both fixed histories and both campaigns. Plain `JSON.stringify` + * printed `Infinity` and `-Infinity` as `null`; restoring it fails the fixed * infinity history and both campaigns. Comparing the join index's source-key * prefixes with `===` let a retracted `NaN`-keyed row survive; restoring it - * fails the pinned `NaN` history. + * fails the fixed `NaN` history. * * The pair-only campaign above does not cover joins over subqueries or more * than two sources. The receiving grammar below adds bounded custom-key and @@ -53,9 +53,12 @@ import { } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -166,7 +169,7 @@ const historyArbitrary: fc.Arbitrary = fc.record({ // and (1, `c`) and (`1`, `c`) share `[1,c]`. Under plain JSON, (`a`, Infinity) // and (`a`, -Infinity) share `["a",null]`, and so do the unmatched right rows // once `a` moves away. -const pinnedHistories: ReadonlyArray = [ +const fixedHistories: ReadonlyArray = [ { join: `inner`, left: [ @@ -271,31 +274,51 @@ async function runHistory(history: History): Promise { ]) } -declareOracle({ check: [runHistory], model: [expectedPairs] }) +declareOracle({ + owns: [ + `src/query/compiler/joins.ts#processJoins`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + check: [runHistory], + model: [expectedPairs], +}) describe(`joined result key oracle`, () => { - // The pinned histories and the fixed campaign stand aside during any path + // The fixed histories and the fixed-seed 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) { + for (const history of fixedHistories) { it(`keeps every ${history.join} join pair for ${history.left .map((row) => typeof row.id === `number` ? String(row.id) : JSON.stringify(row.id), ) - .join(` `)}`, () => runHistory(history)) + .join(` `)}`, () => { + oracleRoute(`case`) + return runHistory(history) + }) } - fcTest.prop([historyArbitrary], { - seed: 44_501_962, + const historyFixedSeed = oracleFixedSeed(44_501_962, { numRuns: oracleRuns(80), - })(`publishes one row per joined pair (fixed)`, runHistory) + }) + fcTest.prop([historyArbitrary], historyFixedSeed)( + `publishes one row per joined pair (fixed)`, + (history) => { + oracleRoute(oracleRouteOf(historyFixedSeed)) + return runHistory(history) + }, + ) } - oracleReplayTest(fcTest, property).prop( - [historyArbitrary], - oraclePropertyOptions(80, property), - )(`publishes one row per joined pair (random)`, runHistory) + const historyCampaign = oraclePropertyOptions(80, property) + oracleReplayTest(fcTest, property).prop([historyArbitrary], historyCampaign)( + `publishes one row per joined pair (random)`, + (history) => { + oracleRoute(oracleRouteOf(historyCampaign)) + return runHistory(history) + }, + ) }) /** @@ -779,15 +802,28 @@ describe(`whole-source joined occurrences`, () => { if (!oracleReplayRequested()) { it.each(selectionExamples)( `preserves every selected occurrence: %j`, - runSelectionHistory, + (history) => { + oracleRoute(`case`) + return runSelectionHistory(history) + }, ) - fcTest.prop([selectionHistoryArbitrary], { - seed: 15_811_743, + const selectionHistoryFixedSeed = oracleFixedSeed(15_811_743, { numRuns: oracleRuns(80), - })(`preserves selected occurrences (fixed)`, runSelectionHistory) + }) + fcTest.prop([selectionHistoryArbitrary], selectionHistoryFixedSeed)( + `preserves selected occurrences (fixed)`, + (history) => { + oracleRoute(oracleRouteOf(selectionHistoryFixedSeed)) + return runSelectionHistory(history) + }, + ) } + const selectionHistoryCampaign = oraclePropertyOptions(80, selectionProperty) oracleReplayTest(fcTest, selectionProperty).prop( [selectionHistoryArbitrary], - oraclePropertyOptions(80, selectionProperty), - )(`preserves selected occurrences (random)`, runSelectionHistory) + selectionHistoryCampaign, + )(`preserves selected occurrences (random)`, (history) => { + oracleRoute(oracleRouteOf(selectionHistoryCampaign)) + return runSelectionHistory(history) + }) }) diff --git a/packages/db/tests/query/limit-admission-oracle.ts b/packages/db/tests/query/limit-admission-oracle.ts index 292400cea3..fbbadf746a 100644 --- a/packages/db/tests/query/limit-admission-oracle.ts +++ b/packages/db/tests/query/limit-admission-oracle.ts @@ -23,7 +23,13 @@ import { Query, getQueryIR } from '../../src/query/builder/index.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' import { createDeferred } from '../../src/deferred.js' import { declareOracle } from '../oracle-declaration.js' -import { oraclePropertyOptions, oracleReplayTest } from '../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleReplayTest, + oracleRoute, + oracleRouteOf, +} from '../oracle-config.js' import { createOracleReach } from '../oracle-reach.js' import { atOracleCheckpoint, @@ -402,6 +408,13 @@ export function registerLimitAdmissionOracle(): void { JSON.stringify(history), ) let count = 0 + const parameters = { + ...oraclePropertyOptions(20, property, { + examples: [[example]], + }), + ...(campaign === `fixed` ? oracleFixedSeed(2291) : {}), + } + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(historyArbitrary, (history) => { @@ -429,12 +442,7 @@ export function registerLimitAdmissionOracle(): void { } }) }), - { - ...oraclePropertyOptions(20, property, { - examples: [[example]], - }), - ...(campaign === `fixed` ? { seed: 2291 } : {}), - }, + parameters, ), ) reach.assertReached() diff --git a/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts b/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts index ed862e1fa4..ad4b13d4c8 100644 --- a/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-joint-replay-oracle.property.test.ts @@ -33,14 +33,23 @@ import { createDeferred } from '../../src/deferred.js' import { createLiveQueryCollection, eq } from '../../src/query/index.js' import { createTransaction } from '../../src/transactions.js' import { runOracleCampaign } from '../oracle-campaign.js' -import { oracleRuns } from '../oracle-config' +import { oracleRoute, oracleRuns } from '../oracle-config' import { flushPromises, withOracleCleanup } from '../utils.js' +import { declareOracle } from '../oracle-declaration' import type { LoadSubsetOptions, SyncAppliedReceipt, SyncConfig, } from '../../src/types.js' +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/transactions.ts#createTransaction', + ], +}) + type Row = { id: string; version: number; value: number; kind: `data` | `hold` } type PublicRow = Pick type Owner = `main` | `peer` @@ -444,6 +453,7 @@ async function runHistory(h: Scenario) { } it(`composes overlapping replay receipts, failure, surviving ownership and restart`, async () => { + oracleRoute(`random seed`) await runOracleCampaign( `JOINT_REPLAY_ORACLE`, [ diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index 8f3b023e76..6ea11aa1a4 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -5,7 +5,10 @@ import { Func, PropRef, Value } from '../../src/query/ir.js' import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { + oracleFixedSeed, oracleRandomParameters, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -277,6 +280,7 @@ const exactScenarioRuns = 40 * multiplier // attempt with the transports that it expects. The grammar is the exact-demand // trace arbitrary, which the calibration test below reads for coverage. declareOracle({ + owns: ['src/query/subset-dedupe.ts'], check: [ assertCompletedExactDemandTrace, assertConcurrentExactDemandTrace, @@ -289,6 +293,7 @@ describe(`exact loadSubset demand oracle`, () => { it.each(exactDemandAxisPairs)( `distinguishes a change to %s in completed and concurrent demands`, async (_axis, first, second) => { + oracleRoute(`case`) assertCompletedExactDemandTrace([first, second, first]) await assertConcurrentExactDemandTrace({ trace: [first, second, first], @@ -298,6 +303,7 @@ describe(`exact loadSubset demand oracle`, () => { ) it(`uses SQL unknown for nullish comparisons in the independent model`, () => { + oracleRoute(`fixed history`) const missing = new PropRef([`missing`]) expect( @@ -312,10 +318,11 @@ describe(`exact loadSubset demand oracle`, () => { }) it(`generates repeated, cursor, empty, and unbounded exact demands`, () => { - const traces = fc.sample(exactDemandTraceArbitrary, { - seed: 1656, - numRuns: 200, - }) + oracleRoute(`fixed seed`) + const traces = fc.sample( + exactDemandTraceArbitrary, + oracleFixedSeed(1656, { numRuns: 200 }), + ) const demands = traces.flat() expect( @@ -332,47 +339,62 @@ describe(`exact loadSubset demand oracle`, () => { expect(new Set(demands.map(({ offset }) => offset)).size).toBeGreaterThan(1) }) - fcTest.prop([exactDemandTraceArbitrary], { + const exactDemandTraceFixedSeed = oracleFixedSeed(1657, { numRuns: exactScenarioRuns, - seed: 1657, - })( + }) + fcTest.prop([exactDemandTraceArbitrary], exactDemandTraceFixedSeed)( `starts each completed exact demand once for a fixed seed`, - assertCompletedExactDemandTrace, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(exactDemandTraceFixedSeed)) + return assertCompletedExactDemandTrace(...args) + }, ) - fcTest.prop( - [exactDemandTraceArbitrary], - oracleRandomParameters( - exactScenarioRuns, - replay, - `load-subset.exact-completion`, - ), - )( + const exactDemandTraceCampaign = oracleRandomParameters( + exactScenarioRuns, + replay, + `load-subset.exact-completion`, + ) + fcTest.prop([exactDemandTraceArbitrary], exactDemandTraceCampaign)( `starts each completed exact demand once for a random or replayed seed`, - assertCompletedExactDemandTrace, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(exactDemandTraceCampaign)) + return assertCompletedExactDemandTrace(...args) + }, ) - fcTest.prop([concurrentExactScenarioArbitrary], { + const concurrentExactScenarioFixedSeed = oracleFixedSeed(1661, { numRuns: exactScenarioRuns, - seed: 1661, - })( + }) + fcTest.prop( + [concurrentExactScenarioArbitrary], + concurrentExactScenarioFixedSeed, + )( `shares only identical in-flight demands for a fixed seed`, - assertConcurrentExactDemandTrace, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(concurrentExactScenarioFixedSeed)) + return assertConcurrentExactDemandTrace(...args) + }, ) + const concurrentExactScenarioCampaign = oracleRandomParameters( + exactScenarioRuns, + replay, + `load-subset.exact-inflight`, + ) fcTest.prop( [concurrentExactScenarioArbitrary], - oracleRandomParameters( - exactScenarioRuns, - replay, - `load-subset.exact-inflight`, - ), + concurrentExactScenarioCampaign, )( `shares only identical in-flight demands for a random or replayed seed`, - assertConcurrentExactDemandTrace, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(concurrentExactScenarioCampaign)) + return assertConcurrentExactDemandTrace(...args) + }, ) it(`reports one rejection to every exact waiter and then retries`, async () => { + oracleRoute(`fixed history`) await expectExactWaitersShareRejection({ demand: { values: [1, 2], @@ -390,9 +412,10 @@ describe(`exact loadSubset demand oracle`, () => { }) }) - fcTest.prop([rejectedWaiterScenarioArbitrary], { + const rejectedWaiterScenarioFixedSeed = oracleFixedSeed< + fc.Parameters> + >(703027, { numRuns: exactScenarioRuns, - seed: 703027, examples: [ [ { @@ -412,20 +435,31 @@ describe(`exact loadSubset demand oracle`, () => { }, ], ], - })( + }) + fcTest.prop( + [rejectedWaiterScenarioArbitrary], + rejectedWaiterScenarioFixedSeed, + )( `retries failed exact demand histories through fresh success for a fixed seed`, - expectExactWaitersShareRejection, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(rejectedWaiterScenarioFixedSeed)) + return expectExactWaitersShareRejection(...args) + }, ) + const rejectedWaiterScenarioCampaign = oracleRandomParameters( + exactScenarioRuns, + replay, + `load-subset.rejected-waiter`, + ) fcTest.prop( [rejectedWaiterScenarioArbitrary], - oracleRandomParameters( - exactScenarioRuns, - replay, - `load-subset.rejected-waiter`, - ), + rejectedWaiterScenarioCampaign, )( `retries failed exact demand histories through fresh success for a random or replayed seed`, - expectExactWaitersShareRejection, + (...args: Parameters) => { + oracleRoute(oracleRouteOf(rejectedWaiterScenarioCampaign)) + return expectExactWaitersShareRejection(...args) + }, ) }) 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 00a7aafafc..1fa7e9f965 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 @@ -14,7 +14,10 @@ import { createOracleReach } from '../oracle-reach.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { flushPromises } from '../utils.js' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../oracle-config.js' @@ -2020,7 +2023,15 @@ async function checkOrderedReplay( // the published window at each checkpoint with the expectation that the history // implies. Its expectation is computed inside the function body, not by a named // model part yet. -declareOracle({ check: [checkOrderedReplay] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/collection/subscription.ts', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + check: [checkOrderedReplay], +}) const orderedReplayProperty = `replay.ordered-window-faults` const orderedReplayConfig = readOracleRunConfig() @@ -2622,7 +2633,7 @@ describe(`Ordered windows across truncate replays`, () => { history: Array scenario?: OrderedScenario reaches?: keyof OrderedReach - // A replay whose history the law places in a recovery. The pin also + // A fixed history that the law places in a recovery. The history also // holds where the model cannot tell which subscription issued a load // (see Limits). recovers?: true @@ -2630,6 +2641,7 @@ describe(`Ordered windows across truncate replays`, () => { }>)( `keeps the ordered window for $name (generated replay)`, async ({ history, scenario, ...replay }) => { + oracleRoute(`case`) const entered = new Set() const reach = await checkOrderedReplay(history, scenario, (state) => entered.add(state), @@ -2654,6 +2666,7 @@ describe(`Ordered windows across truncate replays`, () => { // discarded that replay and published an empty window while the second // failure held nothing; that failure now holds its own subscription. it(`keeps the last publication when a marker change follows two failed replay loads`, async () => { + oracleRoute(`fixed history`) await checkOrderedReplay( [ { type: `hold` }, @@ -2686,6 +2699,7 @@ describe(`Ordered windows across truncate replays`, () => { // and the independent retention account above. A move can retire the failed // demand; a later truncate can replace it with successful own coverage. it(`keeps new include failures behind a loading sibling replay`, async () => { + oracleRoute(`fixed history`) const states = [ `newSiblingReplayFailures`, `newSiblingReplayRetirements`, @@ -2750,6 +2764,7 @@ describe(`Ordered windows across truncate replays`, () => { // A waiting move distinguishes publication retention from replay verdicts: // Atomic window publication allows rejection despite a later retry. it(`keeps publication across sibling and include settlement orders`, async () => { + oracleRoute(`fixed history`) const states = [`lateSiblingSettlements`, `retryWhileSiblingLoads`] as const const declared = createOracleReach( `replay.sibling-settlement-order`, @@ -2845,6 +2860,7 @@ describe(`Ordered windows across truncate replays`, () => { // failures. The same comparison checks both, without an expected error // copied from production or a source-wide error order. it(`keeps replay error order within each subscription`, async () => { + oracleRoute(`fixed history`) const declared = createOracleReach( `replay.subscription-error-order`, [ @@ -2905,6 +2921,7 @@ describe(`Ordered windows across truncate replays`, () => { // the two-failure grammar above. Real adapter subscriptions supply identity; // the shared account comparison requires a sibling's failure to survive. it(`retains sibling replay failures through successful coverage`, async () => { + oracleRoute(`fixed history`) const declared = createOracleReach( `replay.subscription-success-scope`, [`siblingSuccessfulCoverage`, `siblingRequestErrorChecks`] as const, @@ -3049,6 +3066,11 @@ describe(`Ordered windows across truncate replays`, () => { Object.keys(total) as Array ).filter((dimension) => !ownedElsewhere.has(dimension)) const declared = createOracleReach(orderedReplayProperty, dimensions) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(20261021, { numRuns: oracleRuns(300) }) + : oraclePropertyOptions(300, orderedReplayProperty) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array(orderedCommand, { minLength: 1, maxLength: 12 }), @@ -3061,9 +3083,7 @@ describe(`Ordered windows across truncate replays`, () => { } }, ), - campaign === `fixed` - ? { seed: 20261021, numRuns: oracleRuns(300) } - : oraclePropertyOptions(300, orderedReplayProperty), + parameters, ) console.info( orderedReplayProperty, @@ -3450,6 +3470,11 @@ describe(`Ordered windows across truncate replays`, () => { ] as const, { minimum: 5 }, ) + const campaignParameters = + campaign === `fixed` + ? oracleFixedSeed(20261027, { numRuns: oracleRuns(300) }) + : oraclePropertyOptions(300, preloadProperty) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(preloadCase, async ([history, scenario]) => { reach.beginHistory() @@ -3466,9 +3491,7 @@ describe(`Ordered windows across truncate replays`, () => { // builder that lets a discarded replay reject a deferred window move, // and one that keeps a failure before the first ready non-fatal. Each // dies in about half of 200-run seeds. - campaign === `fixed` - ? { seed: 20261027, numRuns: oracleRuns(300) } - : oraclePropertyOptions(300, preloadProperty), + campaignParameters, ) reach.assertReached() }, @@ -3501,6 +3524,11 @@ describe(`Ordered windows across truncate replays`, () => { ] as const, { minimum: 5 }, ) + const runParameters = + campaign === `fixed` + ? oracleFixedSeed(20261023, { numRuns: oracleRuns(600) }) + : oraclePropertyOptions(600, failedReplayProperty) + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty(failedReplayCase, async ([history, scenario]) => { reach.beginHistory() @@ -3539,9 +3567,7 @@ describe(`Ordered windows across truncate replays`, () => { reach.reach(`successWhileSubscriptionOwes`) } }), - campaign === `fixed` - ? { seed: 20261023, numRuns: oracleRuns(600) } - : oraclePropertyOptions(600, failedReplayProperty), + runParameters, ) reach.assertReached() }, @@ -3584,6 +3610,11 @@ it.each(replayDemandCampaigns)( `distinguishes failed demand origins (%s campaign)`, async (campaign) => { const declared = createOracleReach(replayDemandProperty, replayDemandStates) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2233, { numRuns: oracleRuns(75) }) + : oraclePropertyOptions(75, replayDemandProperty) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(replayDemandHistory, async (generated) => { declared.beginHistory() @@ -3624,9 +3655,7 @@ it.each(replayDemandCampaigns)( // barrier, which keeps the query available for a later replay. checkReplayDemandFailure(reach, entered) }), - campaign === `fixed` - ? { seed: 2233, numRuns: oracleRuns(75) } - : oraclePropertyOptions(75, replayDemandProperty), + parameters, ) console.info( replayDemandProperty, @@ -3653,6 +3682,11 @@ it.each(orderedFailureCampaigns)( orderedFailureProperty, [`initiating-keyed-request-failed`, `keyed-reacquisition-failed`], ) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(20261026, { numRuns: oracleRuns(40) }) + : oraclePropertyOptions(40, orderedFailureProperty) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array(orderedCommand, { minLength: 0, maxLength: 6 }), @@ -3692,7 +3726,7 @@ it.each(orderedFailureCampaigns)( ) // A fresh request that fails inside the marker barrier keeps the // query; a later suffix may legitimately end it, so only the - // fresh history is pinned. + // fresh history is checked exactly. if (fresh) { expect(reach.terminalErrors, `fresh request failure`).toBe(0) expect(reach.requestErrors, `fresh request failure`).toBe(1) @@ -3700,9 +3734,7 @@ it.each(orderedFailureCampaigns)( } }, ), - campaign === `fixed` - ? { seed: 20261026, numRuns: oracleRuns(40) } - : oraclePropertyOptions(40, orderedFailureProperty), + parameters, ) console.info( orderedFailureProperty, diff --git a/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts index 1d1e17d839..3de72418f2 100644 --- a/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts @@ -10,6 +10,7 @@ import { } from '../../src/query/index.js' import { declareOracle } from '../oracle-declaration.js' import { flushPromises } from '../utils.js' +import { oracleRoute } from '../oracle-config.js' import { finishCleanup } from './load-subset-source-readiness-support.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -47,7 +48,14 @@ function expectedRows( ] } -declareOracle({ model: [expectedRows] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + model: [expectedRows], +}) it.each([ { oldOutcome: `resolve`, settlementOrder: `old-first` }, @@ -57,6 +65,7 @@ it.each([ ] as const)( `fences a retired source-demand attempt across $settlementOrder $oldOutcome settlement`, async ({ oldOutcome, settlementOrder }) => { + oracleRoute(`case`) type Parent = { id: string; group: string } type Child = { id: string; group: string } type PendingRequest = { diff --git a/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts index 1f6635d0e6..fed4660dd1 100644 --- a/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts @@ -4,6 +4,7 @@ import { createDeferred } from '../../src/deferred.js' import { createTransaction } from '../../src/transactions.js' import { LoadSubsetOperationAbortedError } from '../../src/errors.js' import { declareOracle } from '../oracle-declaration.js' +import { oracleRoute } from '../oracle-config.js' /** * # When can an abort cancel a load? @@ -59,7 +60,13 @@ function expectedOutcome( } } -declareOracle({ model: [expectedOutcome] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/transactions.ts#createTransaction', + ], + model: [expectedOutcome], +}) describe(`loadSubset transaction refinement`, () => { // These three phases cover the contract: @@ -74,6 +81,7 @@ describe(`loadSubset transaction refinement`, () => { ])( `matches the independent receipt and publication model when aborting %s`, async (abortPhase) => { + oracleRoute(`case`) const sourceId = `transaction-refinement-${abortPhase}` const remoteRow: Row = { id: `remote`, group: `requested` } const expected = expectedOutcome(abortPhase, remoteRow.id) diff --git a/packages/db/tests/query/mixed-index-order-oracle.test.ts b/packages/db/tests/query/mixed-index-order-oracle.test.ts index d188a56dc0..fd50daffa8 100644 --- a/packages/db/tests/query/mixed-index-order-oracle.test.ts +++ b/packages/db/tests/query/mixed-index-order-oracle.test.ts @@ -33,6 +33,7 @@ import { PropRef } from '../../src/query/ir.js' import { declareOracle } from '../oracle-declaration.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' +import { oracleRoute } from '../oracle-config.js' import type { Collection } from '../../src/collection/index.js' import type { LoadSubsetOptions, @@ -252,13 +253,23 @@ async function expectIndexedPublicOrder( ]) } -declareOracle({ check: [expectIndexedPublicOrder] }) +declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + check: [expectIndexedPublicOrder], +}) for (const scenario of histories) { for (const IndexType of [BasicIndex, BTreeIndex]) { for (const direction of [`asc`, `desc`] as const) { - it(`${IndexType.name} preserves ${scenario.name} ${direction} public order across kind changes`, () => - expectIndexedPublicOrder(scenario, IndexType, direction)) + it(`${IndexType.name} preserves ${scenario.name} ${direction} public order across kind changes`, () => { + oracleRoute(`case`) + return expectIndexedPublicOrder(scenario, IndexType, direction) + }) } } } @@ -320,6 +331,7 @@ function evaluateNumericRequest( } it(`preserves a retained on-demand window after its index domain becomes mixed`, async () => { + oracleRoute(`fixed history`) const initial: Array = [ { id: `three`, value: 3 }, { id: `five`, value: 5 }, diff --git a/packages/db/tests/query/optimizer-semantics-oracle.test.ts b/packages/db/tests/query/optimizer-semantics-oracle.test.ts index 7ea0d35f2c..56e1f7d4d2 100644 --- a/packages/db/tests/query/optimizer-semantics-oracle.test.ts +++ b/packages/db/tests/query/optimizer-semantics-oracle.test.ts @@ -40,6 +40,7 @@ import { import { CollectionRef, Func, PropRef } from '../../src/query/ir.js' import { declareOracle } from '../oracle-declaration.js' import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' +import { oracleRoute } from '../oracle-config.js' import { expectedTerms, inertCollection, @@ -61,6 +62,7 @@ describe('optimizer aggregate semantics', () => { `caseWhenDefault`, ] as const) { test(`${shape} global aggregate keeps post-aggregate filtering`, () => { + oracleRoute(`case`) const source = createCollection( mockSyncCollectionOptions({ id: `optimizer-aggregate-source-${shape}`, @@ -144,6 +146,7 @@ describe('optimizer aggregate semantics', () => { // predicates toward sources; one on a literal field must stay outside the // aggregate subquery, where it would remove every source row. test('literal-field filtering keeps each aggregate whole under a left join', () => { + oracleRoute(`fixed history`) const source = createCollection( mockSyncCollectionOptions({ id: `optimizer-literal-source`, @@ -185,6 +188,7 @@ describe('optimizer aggregate semantics', () => { }) test('group-key filtering keeps each grouped aggregate under a left join', () => { + oracleRoute(`fixed history`) const source = createCollection( mockSyncCollectionOptions({ id: `optimizer-grouped-source`, @@ -263,6 +267,7 @@ describe(`optimizer residual convergence`, () => { for (const reversed of [false, true]) { for (const twoActive of [false, true]) { test(`${join.type} ${form} reversed=${reversed} twoActive=${twoActive} preserves each predicate once`, () => { + oracleRoute(`case`) const teamKeys = [`team.active=true`] const memberKeys = [`member.userId=100`] const clauses = [ @@ -331,6 +336,7 @@ describe(`optimizer residual convergence`, () => { for (const joinType of [`left`, `right`] as const) { test(`${joinType} join publishes the independently joined rows`, () => { + oracleRoute(`case`) const teamCollection = createCollection( mockSyncCollectionOptions({ id: `optimizer-residual-${joinType}-teams`, @@ -396,6 +402,7 @@ describe(`optimizer residual convergence`, () => { } test(`two active sources publish the independently joined rows`, () => { + oracleRoute(`fixed history`) const tags = [ { id: 20, teamId: 1, region: `west` }, { id: 21, teamId: 2, region: `west` }, @@ -598,6 +605,11 @@ function expectedChainRows( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], model: [ expectedAggregateTotals, expectedLiteralFieldRows, diff --git a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts index 1ce880490d..0d6be8517f 100644 --- a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts @@ -14,7 +14,10 @@ import { BTreeIndex } from '../../src/indexes/btree-index.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { captureMicrotaskErrors, flushPromises } from '../utils.js' import { + oracleFixedSeed, oracleRandomParameters, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -956,6 +959,7 @@ describe(`synchronous initial settlement refinement`, () => { declareOracle({ grammar: [cells] }) it(`enumerates every initial path and settlement cell exactly once`, () => { + oracleRoute(`case`) expect(cells).toHaveLength(4) expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(4) }) @@ -963,6 +967,7 @@ describe(`synchronous initial settlement refinement`, () => { it.each(cells)( `publishes the complete initial window at the $settlement checkpoint with $autoIndex indexing`, async ({ settlement, autoIndex }) => { + oracleRoute(`case`) const observed = await observeInitialSettlement(settlement, autoIndex) assertInitialSettlementObservation(observed.immediate, settlement) expect(observed.settled).toEqual({ @@ -981,6 +986,7 @@ describe(`synchronous initial settlement refinement`, () => { ) it(`rejects the old Promise-wrapped observation at the synchronous checkpoint`, () => { + oracleRoute(`calibration`) expect(() => assertInitialSettlementObservation( expectedInitialSettlementObservation(`promise`), @@ -1014,17 +1020,20 @@ describe(`ordered lifecycle product`, () => { declareOracle({ grammar: [cells] }) it(`keeps all 192 declared histories distinct`, () => { + oracleRoute(`case`) expect(cells).toHaveLength(192) expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(192) }) it.each(cells)( `$acquisitionPath / $delivery / $window / $outcome / $syncRun / $barrier`, async (scenario) => { + oracleRoute(`case`) const result = await assertHistory(scenario) observed.add(JSON.stringify(result.coordinates)) }, ) it(`reaches all 192 histories through physical work and terminal cleanup`, () => { + oracleRoute(`case`) expect(observed.size).toBe(cells.length) }) const arbitrary = fc.record({ @@ -1045,19 +1054,26 @@ describe(`ordered lifecycle product`, () => { rankStep: fc.integer({ min: 1, max: 10 }), }) const { multiplier, ...replay } = readOracleRunConfig() - fcTest.prop([arbitrary], { numRuns: 20 * multiplier, seed: 93471 })( + const fixedCampaign = oracleFixedSeed(93471, { + numRuns: 20 * multiplier, + }) + fcTest.prop([arbitrary], fixedCampaign)( `matches the ordered lifecycle for a fixed seed`, async (scenario) => { + oracleRoute(oracleRouteOf(fixedCampaign)) await assertHistory(scenario) }, Math.max(10000, multiplier * 1500), ) - fcTest.prop( - [arbitrary], - oracleRandomParameters(20 * multiplier, replay, `ordered-work.lifecycle`), - )( + const randomCampaign = oracleRandomParameters( + 20 * multiplier, + replay, + `ordered-work.lifecycle`, + ) + fcTest.prop([arbitrary], randomCampaign)( `matches the ordered lifecycle for a random or replayed seed`, async (scenario) => { + oracleRoute(oracleRouteOf(randomCampaign)) await assertHistory(scenario) }, Math.max(10000, multiplier * 1500), @@ -1275,6 +1291,7 @@ describe(`initial locale-string fallback oracle`, () => { declareOracle({ grammar: [cells] }) it(`enumerates every index, settlement, and row-order cell exactly once`, () => { + oracleRoute(`case`) expect(cells).toHaveLength(8) expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(8) }) @@ -1282,6 +1299,7 @@ describe(`initial locale-string fallback oracle`, () => { it.each(cells)( `publishes the $autoIndex $order locale-string singleton at the $settlement checkpoint`, async ({ autoIndex, settlement, order }) => { + oracleRoute(`case`) const observed = await observeInitialFallback( autoIndex, settlement, @@ -1313,6 +1331,7 @@ describe(`initial locale-string fallback oracle`, () => { it.each(cells)( `emits one completed $autoIndex $order initial window change with $settlement fallback`, async ({ autoIndex, order, settlement }) => { + oracleRoute(`case`) const observed = await observeInitialFallback( autoIndex, settlement, @@ -2178,6 +2197,7 @@ describe(`post-publication null fallback oracle`, () => { it.each(cases)( `keeps the public window and change events coherent after $mutation with $response and $childDemand child demand`, async ({ mutation, response, childDemand }) => { + oracleRoute(`case`) await observeLaterFallback(mutation, response, childDemand, (observed) => assertLaterFallbackObservation(mutation, observed), ) @@ -2185,6 +2205,7 @@ describe(`post-publication null fallback oracle`, () => { ) it(`rejects a stale window event after an overlapping held provider result`, async () => { + oracleRoute(`calibration`) await observeLaterFallback( `delete-marker`, `held-promise`, @@ -2229,6 +2250,7 @@ describe(`post-publication null fallback oracle`, () => { it.each([`delete-marker`, `retarget-marker`] as const)( `reports the first lease's cleanup failure after the %s fallback publishes its replacement`, async (mutation) => { + oracleRoute(`case`) // The subscription reports the throwing release uncaught, once: it is // an adapter bug, not a load result. const uncaught = captureMicrotaskErrors() @@ -2253,6 +2275,7 @@ describe(`post-publication null fallback oracle`, () => { describe(`nullable multi-term lifecycle product`, () => { it(`anchors the nullable reference and controlled provider to an independent order palette`, () => { + oracleRoute(`case`) for (const primaryNulls of [`first`, `last`] as const) for (const primaryDirection of [`asc`, `desc`] as const) for (const secondaryNulls of [`first`, `last`] as const) @@ -2314,6 +2337,7 @@ describe(`nullable multi-term lifecycle product`, () => { declareOracle({ grammar: [cells, directionCells] }) it(`enumerates every nullable lifecycle and direction-overlay cell exactly once`, () => { + oracleRoute(`case`) expect(cells).toHaveLength(22) expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(22) expect( @@ -2332,6 +2356,7 @@ describe(`nullable multi-term lifecycle product`, () => { it.each(cells)( `observes nullable tie and lifecycle coordinates: %j`, async (scenario) => { + oracleRoute(`case`) const result = await assertHistory(scenario) expect(result.orderedRequests).toBeGreaterThan(0) expect( @@ -2345,6 +2370,7 @@ describe(`nullable multi-term lifecycle product`, () => { it.each(directionCells)( `keeps primary $primaryDirection and secondary $secondaryDirection direction independent`, async ({ primaryDirection, secondaryDirection }) => { + oracleRoute(`case`) const representative = cells[0]! await assertHistory({ ...representative, @@ -2364,6 +2390,7 @@ describe(`nullable multi-term lifecycle product`, () => { it.each([`secondary-order`, `null-placement`] as const)( `rejects a wrong %s through live loader output`, async (fault) => { + oracleRoute(`calibration`) const scenario = cells[0]! await assertHistory(scenario) await expect(assertHistory(scenario, fault)).rejects.toMatchObject({ @@ -2405,7 +2432,11 @@ describe(`nullable multi-term lifecycle product`, () => { declareOracle({ grammar: [arbitrary] }) it(`generates the nullable value and lifecycle dimensions`, () => { - const sample = fc.sample(arbitrary, { seed: 93472, numRuns: 100 }) + oracleRoute(`fixed seed`) + const sample = fc.sample( + arbitrary, + oracleFixedSeed(93472, { numRuns: 100 }), + ) expect(new Set(sample.map((scenario) => scenario.acquisitionPath))).toEqual( new Set([`prefix`, `boundary`, `full-source`]), ) @@ -2445,21 +2476,28 @@ describe(`nullable multi-term lifecycle product`, () => { } const nullableRunBudget = 20 * multiplier const nullableTimeout = Math.max(10000, multiplier * 1500) - fcTest.prop([arbitrary], { numRuns: nullableRunBudget, seed: 93472 })( + const fixedCampaign2 = oracleFixedSeed(93472, { + numRuns: nullableRunBudget, + }) + fcTest.prop([arbitrary], fixedCampaign2)( `matches nullable multi-term lifecycle histories for a fixed seed`, - assertNullableHistory, + async (scenario) => { + oracleRoute(oracleRouteOf(fixedCampaign2)) + await assertNullableHistory(scenario) + }, nullableTimeout, ) - fcTest.prop( - [arbitrary], - oracleRandomParameters( - nullableRunBudget, - replay, - `ordered-work.nullable-lifecycle`, - ), - )( + const randomCampaign2 = oracleRandomParameters( + nullableRunBudget, + replay, + `ordered-work.nullable-lifecycle`, + ) + fcTest.prop([arbitrary], randomCampaign2)( `matches nullable multi-term lifecycle histories for a random or replayed seed`, - assertNullableHistory, + async (scenario) => { + oracleRoute(oracleRouteOf(randomCampaign2)) + await assertNullableHistory(scenario) + }, nullableTimeout, ) }) @@ -2475,6 +2513,7 @@ describe(`synchronous initial full-source settlement refinement`, () => { declareOracle({ grammar: [cells] }) it(`enumerates every full-source feature and settlement cell exactly once`, () => { + oracleRoute(`case`) expect(cells).toHaveLength(8) expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(8) }) @@ -2482,6 +2521,7 @@ describe(`synchronous initial full-source settlement refinement`, () => { it.each(cells)( `publishes the complete initial $feature window at the $settlement checkpoint`, async ({ feature, settlement }) => { + oracleRoute(`case`) const observed = await observeFullSourceSettlement(feature, settlement) assertFullSourceRequest(observed.requests[0]) assertInitialSettlementObservation(observed.immediate, settlement) @@ -2492,6 +2532,7 @@ describe(`synchronous initial full-source settlement refinement`, () => { it.each(fullSourceFeatures)( `publishes the complete initial %s window over an eager source at the synchronous checkpoint`, async (feature) => { + oracleRoute(`case`) // An eager source answers every local snapshot synchronously. assertInitialSettlementObservation( await observeEagerFullSourceSettlement(feature), @@ -2504,8 +2545,13 @@ describe(`synchronous initial full-source settlement refinement`, () => { // The refinement checks compare production observations with the models they // name. observeHistory compares each history with its finite reference inside // the driver. compareNullable is the nullable-order model; the palette table is -// its authority, and expectNullableOrderMatchesPalette pins the model to it. +// its authority, and expectNullableOrderMatchesPalette anchors the model to it. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [ observeHistory, assertInitialSettlementObservation, diff --git a/packages/db/tests/query/ordered-lifecycle.test.ts b/packages/db/tests/query/ordered-lifecycle.test.ts index fbdcd30593..c27bb0866f 100644 --- a/packages/db/tests/query/ordered-lifecycle.test.ts +++ b/packages/db/tests/query/ordered-lifecycle.test.ts @@ -110,7 +110,7 @@ describe(`synchronous initial settlement refinement`, () => { // initial load installs every source row. #2055's hazard needs a bounded read // that left an eligible row out; after a full read the window can always // reach the next eligible row, even when a distant row entered through a live - // update first. This pins that the synchronous initial load keeps that + // update first. This checks that the synchronous initial load keeps that // property through later changes. it(`keeps the first eligible joined rows after a synchronous full-source load`, async () => { type Row = { id: number; rank: number; label: string } diff --git a/packages/db/tests/query/ordered-source-loader-oracle.test.ts b/packages/db/tests/query/ordered-source-loader-oracle.test.ts index 675d25293f..61712a5e9c 100644 --- a/packages/db/tests/query/ordered-source-loader-oracle.test.ts +++ b/packages/db/tests/query/ordered-source-loader-oracle.test.ts @@ -5,6 +5,7 @@ // this area are fixed histories, owned by ordered-source-loader.test.ts. import { describe, expect, it, vi } from 'vitest' import { OrderedSourceLoader } from '../../src/query/live/ordered-source-loader.js' +import { oracleRoute } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { createDeferred, @@ -37,12 +38,14 @@ const expectedStagedWindowTrace = ( ] declareOracle({ + owns: ['src/query/live/ordered-source-loader.ts'], model: [expectedStagedWindowTrace], grammar: [stagedWindowCases], }) describe(`OrderedSourceLoader`, () => { it(`enumerates every staged-boundary explicit-window cell exactly once`, () => { + oracleRoute(`case`) const keys = stagedWindowCases.map( ({ boundary, demand }) => `${boundary}:${demand}`, ) @@ -54,6 +57,7 @@ describe(`OrderedSourceLoader`, () => { it.each(stagedWindowCases)( `refines staged boundary $boundary with $demand explicit-window demand`, async ({ boundary, demand }) => { + oracleRoute(`case`) type ObservedRequest = { kind: `page` | `boundary` options: RequestOptions 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 3a5cea4529..df24b51194 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 @@ -8,7 +8,10 @@ import { PropRef } from '../../src/query/ir.js' import { createOracleReach } from '../oracle-reach.js' import { captureMicrotaskErrors, flushPromises } from '../utils.js' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../oracle-config.js' @@ -2358,6 +2361,13 @@ const authorityCampaigns = // the request ledger. The grammar is authorityCaseArbitrary, which the campaigns // and the calibration witnesses below read for coverage. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/effect.ts#createEffect', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/live/ordered-source-loader.ts', + ], check: [checkAuthorityCase, checkRetirementProgress, checkRetirementReceiver], grammar: [authorityCaseArbitrary], }) @@ -2384,6 +2394,7 @@ describe(`Ordered source request authority`, () => { )( `rejects substituted participant errors across current failures: %j`, async (axes) => { + oracleRoute(`case`) const testCase: AuthorityCase = { ...ordinaryScenario, ...axes, @@ -2415,7 +2426,8 @@ describe(`Ordered source request authority`, () => { // The model's strand trace: a repair supersedes the in-flight tie and // finishes; a newer invalidation's reaction is deferred; the old tie fails. - it(`recovers a superseded tie that fails after its repair finished (generated-lane replay)`, async () => { + it(`recovers a superseded tie that fails after its repair finished (generated campaign replay)`, async () => { + oracleRoute(`fixed history`) await checkAuthorityCase({ indexed: true, limit: 1, @@ -2435,8 +2447,8 @@ describe(`Ordered source request authority`, () => { }) }) - // Traces the generated lane found, kept as replays. Each ran through the - // same laws and failed before its repair: + // Traces the generated campaign found, kept as fixed regressions. Each ran + // through the same laws and failed before its repair: // - a release callback throws while a repair finishes, a superseded tie // fails, and an explicit window move resumes the repair (a throwing // release unwound the chain mid-step, so nothing resumed it); @@ -2453,7 +2465,7 @@ describe(`Ordered source request authority`, () => { // - a repair tie from before a truncate that fails during the next repair // (the dead failure dropped the new chain's staged step, so nothing // resumed it); - // - the same replay trace, once one request at a time was checked (the + // - the same fixed trace, once one request at a time was checked (the // replaced chain's prefix counted as current and issued a tie beside the // replacement's prefix). // - a repair prefix that settles after full-source coverage completed (a @@ -2474,9 +2486,9 @@ describe(`Ordered source request authority`, () => { // start error replaced the request's own error in its participant); // A superseded page that fails before a covering replay settles reaches // the failed-lease law through a replay, which the campaigns rarely do. - // Two replays keep traces that first failed the lane's own model: a chain - // step it took for a chain start (an optimistic refill, and a shrunk - // window's prefix deferred by joined work). + // Two fixed regressions keep traces that first failed the generated + // campaign's own model: a chain step it took for a chain start (an optimistic + // refill, and a shrunk window's prefix deferred by joined work). it.each([ { name: `a throwing release during a window move that resumes a repair`, @@ -2897,8 +2909,9 @@ describe(`Ordered source request authority`, () => { }, }, ] satisfies Array<{ name: string; testCase: AuthorityCase }>)( - `keeps request authority for $name (generated-lane replay)`, + `keeps request authority for $name (generated campaign replay)`, async ({ testCase }) => { + oracleRoute(`case`) await checkAuthorityCase(testCase) }, ) @@ -2908,6 +2921,7 @@ describe(`Ordered source request authority`, () => { // leases before the pass resumed that refill. It remains one repair chain. // Source: https://github.com/TanStack/db/issues/2296 it(`keeps a deferred repair refill in its chain after replay retirement`, async () => { + oracleRoute(`fixed history`) await checkAuthorityCase({ indexed: false, limit: 1, @@ -2936,7 +2950,7 @@ describe(`Ordered source request authority`, () => { // there is no replay bookkeeping to distinguish from a graph pass. // `deferred-refill` needs a synchronous answer and a release-triggered // truncate together: 6 of the 96 inputs. An eager source never reaches its - // adapter on release, so production cannot pair them; the lane calibrates + // adapter on release, so production cannot pair them; the campaign calibrates // the driver's continuation provenance rather than a reachable product state. // Random draws run twice the fixed count so that rare state stays above its // minimum (expected 25 of 400). @@ -2985,6 +2999,11 @@ describe(`Ordered source request authority`, () => { [`deferred-refill`, `reset-continuation`] as const, { minimum: 5 }, ) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(20261029, { numRuns: oracleRuns(200) }) + : oraclePropertyOptions(400, handoffProperty) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(handoffCase, async (testCase) => { reach.beginHistory() @@ -2993,9 +3012,7 @@ describe(`Ordered source request authority`, () => { reach.reach(state) }) }), - campaign === `fixed` - ? { seed: 20261029, numRuns: oracleRuns(200) } - : oraclePropertyOptions(400, handoffProperty), + parameters, ) reach.assertReached() process.stderr.write( @@ -3066,6 +3083,11 @@ describe(`Ordered source request authority`, () => { ] as const, { minimum: 20 }, ) + const campaignParameters = + campaign === `fixed` + ? oracleFixedSeed(20261029, { numRuns: oracleRuns(200) }) + : oraclePropertyOptions(200, retirementProperty) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(retirementCase, async (testCase) => { reach.beginHistory() @@ -3078,9 +3100,7 @@ describe(`Ordered source request authority`, () => { reach.reach(state) }) }), - campaign === `fixed` - ? { seed: 20261029, numRuns: oracleRuns(200) } - : oraclePropertyOptions(200, retirementProperty), + campaignParameters, ) reach.assertReached() process.stderr.write( @@ -3205,6 +3225,11 @@ describe(`Ordered source request authority`, () => { ] as const, { minimum: 20 }, ) + const runParameters = + campaign === `fixed` + ? oracleFixedSeed(20261029, { numRuns: oracleRuns(600) }) + : oraclePropertyOptions(600, retirementReceiverProperty) + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty(retirementReceiverCase, async (scenario) => { reach.beginHistory() @@ -3216,9 +3241,7 @@ describe(`Ordered source request authority`, () => { ), ) }), - campaign === `fixed` - ? { seed: 20261029, numRuns: oracleRuns(600) } - : oraclePropertyOptions(600, retirementReceiverProperty), + runParameters, ) reach.assertReached() process.stderr.write( @@ -3227,9 +3250,9 @@ describe(`Ordered source request authority`, () => { }, ) - // Fixed replays force a held answer in each real consumer. They use the + // Fixed histories force a held answer in each real consumer. They use the // same model and comparison as the generated receiving campaign, and keep - // hostile-mutant failure independent for the two publication paths. + // mutant failure independent for the two publication paths. it.each([ [`collection`, `move`], [`collection-window`, `move`], @@ -3242,6 +3265,7 @@ describe(`Ordered source request authority`, () => { ] as const)( `receives retirement invalidation in a held %s replay (unload: %s)`, async (kind, unloadChange) => { + oracleRoute(`case`) await checkRetirementReceiver( { indexed: false, @@ -3271,6 +3295,7 @@ describe(`Ordered source request authority`, () => { it.each([`before-invalidation`, `after-publication`] as const)( `rejects a retirement start observed %s`, (fault) => { + oracleRoute(`calibration`) const startedAt = fault === `before-invalidation` ? 1 : 4 expect(() => checkRetirementProgress(2, [startedAt], 4)).toThrow( `retirement invalidation`, @@ -3283,6 +3308,7 @@ describe(`Ordered source request authority`, () => { // at comparison. This works for source and minified builds without reading // private members. The same history first proves the unmodified path agrees. it(`rejects retained successful acquisition observations after a covering repair`, async () => { + oracleRoute(`calibration`) const testCase: AuthorityCase = { ...ordinaryScenario, indexed: true, @@ -3363,6 +3389,11 @@ describe(`Ordered source request authority`, () => { hungRequests: 0, offsetWindows: 0, } + const parameters = + campaign === `fixed` + ? oracleFixedSeed(20261016, { numRuns: oracleRuns(10000) }) + : oraclePropertyOptions(10000, authorityProperty) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(authorityCaseArbitrary, async (testCase) => { identityReach.beginHistory() @@ -3405,9 +3436,7 @@ describe(`Ordered source request authority`, () => { later[key] += observation[key] } }), - campaign === `fixed` - ? { seed: 20261016, numRuns: oracleRuns(10000) } - : oraclePropertyOptions(10000, authorityProperty), + parameters, ) // Reach witnesses: superseded requests fail, stale requests settle // after a repair succeeded, the strand law is checkable, a superseded @@ -3597,6 +3626,11 @@ describe(`Ordered source request authority`, () => { const reach = createOracleReach(unprovenReportProperty, reportStates, { minimum: 20, }) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(20261023, { numRuns: oracleRuns(1000) }) + : oraclePropertyOptions(1000, unprovenReportProperty) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(unprovenReportCase, async (testCase) => { reach.beginHistory() @@ -3605,9 +3639,7 @@ describe(`Ordered source request authority`, () => { if (observation[state] > 0) reach.reach(state) } }), - campaign === `fixed` - ? { seed: 20261023, numRuns: oracleRuns(1000) } - : oraclePropertyOptions(1000, unprovenReportProperty), + parameters, ) reach.assertReached() }, diff --git a/packages/db/tests/query/ordered-work-oracle.property.test.ts b/packages/db/tests/query/ordered-work-oracle.property.test.ts index 6b283bd586..51a58459ba 100644 --- a/packages/db/tests/query/ordered-work-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-work-oracle.property.test.ts @@ -12,7 +12,10 @@ import { DeduplicatedLoadSubset } from '../../src/query/subset-dedupe.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' import { eq, gte, isUndefined, not } from '../../src/query/builder/functions.js' import { + oracleFixedSeed, oracleRandomParameters, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -78,7 +81,7 @@ import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' * mutation or repair checkpoint. Adapter requests and work counts are separate * observations; they are not public rows or evidence of remote provider work. * - * Counts are contract bounds, not timing benchmarks. They pin established + * Counts are contract bounds, not timing benchmarks. They check established * request and graph-scheduling behavior only where the test names that promise. * These scenarios exercise local evaluation; they do not model a remote adapter * applying a relation hint. @@ -1600,6 +1603,7 @@ async function observeHeldEffectDelta( describe(`ordered source work oracle`, () => { it(`reconstructs every legal small scenario exactly once`, () => { + oracleRoute(`case`) expect(exhaustiveScenarios).toHaveLength(64) expect( new Set(exhaustiveScenarios.map((scenario) => JSON.stringify(scenario))) @@ -1608,6 +1612,7 @@ describe(`ordered source work oracle`, () => { }) it(`keeps later order-term invalidation equal across consumers`, async () => { + oracleRoute(`fixed history`) const [collection, effect] = await Promise.all([ observeLaterOrderTermMutation(`collection`), observeLaterOrderTermMutation(`effect`), @@ -1621,6 +1626,7 @@ describe(`ordered source work oracle`, () => { it.each([`move`, `delete`] as const)( `recovers a finite source prefix equally across consumers after %s`, async (mutation) => { + oracleRoute(`case`) const [collection, effect] = await Promise.all([ observeFinitePrefixMutation(`collection`, mutation), observeFinitePrefixMutation(`effect`, mutation), @@ -1632,16 +1638,19 @@ describe(`ordered source work oracle`, () => { ) it(`holds the last complete Effect result across an asynchronous prefix and synchronous tie repair`, async () => { + oracleRoute(`fixed history`) await expect(observeHeldEffectRepair(`asynchronous`)).resolves.toEqual([ [2], ]) }) it(`publishes one Effect replacement when ordered repair settles synchronously`, async () => { + oracleRoute(`fixed history`) await expect(observeHeldEffectRepair(`synchronous`)).resolves.toEqual([[2]]) }) it(`publishes the repaired ordered Effect delta when a source changes during startup`, async () => { + oracleRoute(`fixed history`) // Fixed history: the first ordered page contains row 1. Subscribing to // the sibling updates row 1 before the Effect can run its initial graph. // The independent final rank order is row 2, then row 1. At the first @@ -1785,6 +1794,7 @@ describe(`ordered source work oracle`, () => { }) it(`keeps an Effect live when truncate replay aborts an obsolete repair participant`, async () => { + oracleRoute(`fixed history`) await expect( observeEffectAfterObsoleteRepairAbort(), ).resolves.toMatchObject({ @@ -1794,6 +1804,7 @@ describe(`ordered source work oracle`, () => { }) it(`suppresses an asynchronous initial ordered window when skipInitial is set`, async () => { + oracleRoute(`fixed history`) await expect(observeSkipInitialOrderedLoad()).resolves.toEqual({ initialEvents: [], laterEvents: expect.arrayContaining([`enter:3`, `exit:1`]), @@ -1801,6 +1812,7 @@ describe(`ordered source work oracle`, () => { }) it(`restarts bounded repair when another order mutation arrives during its tie request`, async () => { + oracleRoute(`fixed history`) await expect(observeMutationDuringBoundedRepair()).resolves.toMatchObject({ rows: [3], publications: [[3]], @@ -1808,6 +1820,7 @@ describe(`ordered source work oracle`, () => { }) it(`emits no Effect delta for a row that enters and exits during held repair`, async () => { + oracleRoute(`fixed history`) await expect(observeHeldEffectDelta(`absent-cycle`)).resolves.toMatchObject( { rows: [3], @@ -1816,6 +1829,7 @@ describe(`ordered source work oracle`, () => { }) it(`emits the published value when an updated row exits during held repair`, async () => { + oracleRoute(`fixed history`) await expect( observeHeldEffectDelta(`present-update-exit`), ).resolves.toMatchObject({ rows: [3, 4] }) @@ -1837,6 +1851,7 @@ describe(`ordered source work oracle`, () => { )( `does not refetch when a visible row changes outside the ordering key: %j`, async ({ consumer, limit, position, direction }) => { + oracleRoute(`case`) let sync!: Parameters[`sync`]>[0] let loads = 0 const rows = rowsForScenario({ @@ -1945,6 +1960,7 @@ describe(`ordered source work oracle`, () => { ])( `waits for a $name joined source after exhausting tied ordered rows`, async ({ name, secondaryRows, expected }) => { + oracleRoute(`case`) type Primary = { id: string; rank: number; joinKey: string } type Secondary = { id: string; joinKey: string } const primaryRows: Array = [`a`, `b`, `c`, `d`].map((id) => ({ @@ -2093,6 +2109,7 @@ describe(`ordered source work oracle`, () => { ) it(`keeps independent joined loads isolated when they settle in reverse`, async () => { + oracleRoute(`fixed history`) type Primary = { id: string; rank: number; joinKey: string } type Secondary = { id: string; joinKey: string } const pending: Array<{ @@ -2216,6 +2233,7 @@ describe(`ordered source work oracle`, () => { }) it(`keeps one source's replay from suppressing another source's recovery`, async () => { + oracleRoute(`fixed history`) type Primary = { id: number; rank: number } type Secondary = { id: number; primaryId: number } const primaryTruth = new Map([ @@ -2368,6 +2386,7 @@ describe(`ordered source work oracle`, () => { }) it(`publishes one complete batch after an indexed loader fills a window`, async () => { + oracleRoute(`fixed history`) const remoteRows: ReadonlyArray = [ { id: 1, rank: 1, eligible: true, label: `one` }, { id: 2, rank: 2, eligible: true, label: `two` }, @@ -2449,12 +2468,14 @@ describe(`ordered source work oracle`, () => { }) it(`keeps live collections and Effects equal across the exhaustive small domain`, async () => { + oracleRoute(`case`) for (const scenario of exhaustiveScenarios) { await assertConsumerParity(scenario) } }) it(`keeps the collection anti-join result when the joined row exists`, async () => { + oracleRoute(`fixed history`) const observed = await observeConsumer( `collection`, exhaustiveScenarios[0]!, @@ -2464,6 +2485,7 @@ describe(`ordered source work oracle`, () => { }) it(`removes a matched parent from the anti-join Effect`, async () => { + oracleRoute(`fixed history`) const observed = await observeConsumer( `effect`, exhaustiveScenarios[0]!, @@ -2473,6 +2495,7 @@ describe(`ordered source work oracle`, () => { }) it(`processes a synchronous root refill before joined publication`, async () => { + oracleRoute(`fixed history`) type Root = { id: number; rank: number } type Child = { id: number; rootId: number } const truth: Array = [ @@ -2606,6 +2629,7 @@ describe(`ordered source work oracle`, () => { }) it(`resumes bounded ordered repair after joined demand settles`, async () => { + oracleRoute(`fixed history`) type Root = { id: number; rank: number } type Child = { id: number; rootId: number } const truth: Array = [ @@ -2749,6 +2773,7 @@ describe(`ordered source work oracle`, () => { }) it(`bounds ordered root acquisition for a none left-join filter`, async () => { + oracleRoute(`fixed history`) for (const scenario of exhaustiveScenarios) { const observed = await observeConsumer(`collection`, scenario, `none`) expect(observed.requests.length).toBeGreaterThan(0) @@ -2760,6 +2785,7 @@ describe(`ordered source work oracle`, () => { it.each([`joined-only`, `some`, `to-one`] as const)( `bounds ordered root acquisition for a %s left-join filter`, async (joinFilter) => { + oracleRoute(`case`) for (const scenario of exhaustiveScenarios) { const [collection, effect] = await Promise.all([ observeConsumer(`collection`, scenario, joinFilter), @@ -2789,6 +2815,7 @@ describe(`ordered source work oracle`, () => { )( `settles joined demand and publishes a complete $loadingMode $consumer window`, async ({ consumer, loadingMode }) => { + oracleRoute(`case`) const observed = await observeConsumer( consumer, { @@ -2831,6 +2858,7 @@ describe(`ordered source work oracle`, () => { )( `waits for a $joinFilter joined demand before requesting another root page in a $consumer`, async ({ consumer, joinFilter }) => { + oracleRoute(`case`) const observed = await observeConsumer( consumer, { @@ -2851,6 +2879,7 @@ describe(`ordered source work oracle`, () => { it.each([`collection`, `effect`] as const)( `refills a joined-filter %s window after a child leaves`, async (consumer) => { + oracleRoute(`case`) const observed = await observeConsumer( consumer, { @@ -2872,6 +2901,7 @@ describe(`ordered source work oracle`, () => { it.each([`collection`, `effect`] as const)( `stops a joined-filter %s after the needed root prefix`, async (consumer) => { + oracleRoute(`case`) const observed = await observeConsumer( consumer, { @@ -2901,6 +2931,7 @@ describe(`ordered source work oracle`, () => { )( `settles an underfilled source without repeating a continuation: %j`, async ({ middleCount, direction, tied }) => { + oracleRoute(`case`) const scenario: Scenario = { middleCount, middleEligible: false, @@ -2945,6 +2976,7 @@ describe(`ordered source work oracle`, () => { ) it(`replaces an ordered snapshot after truncate without repeating void loads`, async () => { + oracleRoute(`fixed history`) const initial: ReadonlyArray = [ { id: 1, rank: 1, eligible: true, label: `old-one` }, { id: 2, rank: 2, eligible: true, label: `old-two` }, @@ -3065,6 +3097,7 @@ describe(`ordered source work oracle`, () => { }, }, ])(`keeps an ordered snapshot unchanged $name`, async ({ failure }) => { + oracleRoute(`case`) const makeRows = (ranks: ReadonlyArray): Array => ranks.map((rank, index) => ({ id: index + 1, @@ -3278,6 +3311,7 @@ describe(`ordered source work oracle`, () => { it.each([`collection`, `effect`] as const)( `continues an ordered anti-join %s while an unrelated include demand is pending`, async (kind) => { + oracleRoute(`case`) const rows = [ { id: 1, rank: 1 }, { id: 2, rank: 2 }, @@ -3439,17 +3473,26 @@ describe(`ordered source work oracle`, () => { const { multiplier, ...replay } = readOracleRunConfig() const runs = 20 * multiplier - fcTest.prop([scenarioArbitrary], { numRuns: runs, seed: 17801 })( + const scenarioFixedSeed = oracleFixedSeed(17801, { numRuns: runs }) + fcTest.prop([scenarioArbitrary], scenarioFixedSeed)( `keeps rows, request traces, batches, errors, and liveness equal for a fixed seed`, - assertConsumerParity, + async (scenario) => { + oracleRoute(oracleRouteOf(scenarioFixedSeed)) + await assertConsumerParity(scenario) + }, ) - fcTest.prop( - [scenarioArbitrary], - oracleRandomParameters(runs, replay, `ordered-work.consumer-parity`), - )( + const scenarioCampaign = oracleRandomParameters( + runs, + replay, + `ordered-work.consumer-parity`, + ) + fcTest.prop([scenarioArbitrary], scenarioCampaign)( `keeps rows, request traces, batches, errors, and liveness equal for a random or replayed seed`, - assertConsumerParity, + async (scenario) => { + oracleRoute(oracleRouteOf(scenarioCampaign)) + await assertConsumerParity(scenario) + }, ) }) @@ -3609,8 +3652,8 @@ describe(`ordered source work oracle`, () => { * `offset + limit + 1` rows. Its rows law holds beside the first query's at * every checkpoint; the work laws count one consumer, so they do not apply * to those cases. A peer over a refetching provider with a joined filter is - * excluded from the grammar until #2155 is fixed: an indexed trace is fixed - * and pinned, but an unindexed peer still keeps a short joined window after + * excluded from the grammar until #2155 is fixed: an indexed trace is kept as a + * fixed history, but an unindexed peer still keeps a short joined window after * a refetch. A peer over a source filter stays in the grammar. * * `null` and `undefined` ranks are both nullish, so they tie: either may @@ -3829,7 +3872,7 @@ const eagerCaseBase: fc.Arbitrary = fc.record({ }) // A peer over a refetching on-demand provider can keep a short joined window -// after a refetch (#2155). An indexed plan's trace is fixed and pinned below, +// after a refetch (#2155). An indexed plan's trace is kept as a fixed history below, // but an unindexed one still fails (a peer shows [100] where the model shows // [100, 101]). Each campaign excludes only a joined, anti-joined, or full-joined // filter in that pairing, as its last map, until the fix; a source filter @@ -4084,7 +4127,7 @@ const eagerHeldCase: fc.Arbitrary = fc // A forwarding provider sends the delete, and an indexed source filter // or a joined filter refills from the provider, so the refill is a held // request. An unindexed source filter issues no held request. With the - // provider pinned, this campaign needs no peer exclusion (#2155 concerns + // provider fixed, this campaign needs no peer exclusion (#2155 concerns // only a refetching provider). provider: `forward` as const, filter: @@ -6404,6 +6447,7 @@ describe(`eager indexed ordered windows`, () => { const runs = 40 * multiplier it(`places null ranks first in an indexed descending window`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `sync`, filter: `source`, @@ -6418,6 +6462,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`refills after a visible delete without resending the source`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `local-only`, filter: `source`, @@ -6443,6 +6488,7 @@ describe(`eager indexed ordered windows`, () => { it.each([`sync`, `local-only`] as const)( `publishes one complete window when a repair reaches a NaN tie group (%s)`, async (path) => { + oracleRoute(`case`) // The bounded prefix ends inside a NaN tie group, which cannot be a // cursor, so the repair falls back to a full-source read. await checkEagerWindow({ @@ -6468,6 +6514,7 @@ describe(`eager indexed ordered windows`, () => { ) it(`refills a joined window over several pages after a refetch empties it`, async () => { + oracleRoute(`fixed history`) // Every row the window held changes while the refetching provider lags. // The final refetch empties the window, and the loader refills it one // page at a time, publishing [], then one row, then the final two (#2131). @@ -6499,6 +6546,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`shows a row that moved between two served pages after a refetch`, async () => { + oracleRoute(`fixed history`) // The loader pages the top two rows, then one more after a cursor. Rows // 10 and 11 arrive while the provider lags; the refetched first page now // ends at row 11, so row 10 sorts between the pages. A provider that @@ -6534,6 +6582,7 @@ describe(`eager indexed ordered windows`, () => { // reports the shift. Row 10 is in no answer after the refetch; the // report makes the loader re-establish the window with a bounded repair. it(`shows a row that moved between two served pages after a signalling refetch`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6565,6 +6614,7 @@ describe(`eager indexed ordered windows`, () => { // the window is short. The refill grows the prefix instead of reading the // full source, which the refetch law forbids. it(`grows the prefix of a short unindexed window after a signalling refetch`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6594,6 +6644,7 @@ describe(`eager indexed ordered windows`, () => { // short prefix took the full-source fallback. The prefix now grows, so the // refetch law, which allows no full-source read here, holds. it(`reads no full source after a signalling refetch past a NaN boundary`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6625,6 +6676,7 @@ describe(`eager indexed ordered windows`, () => { // short prefix took the full-source fallback, twice, after a signalling // refetch; the refetch law allows none. it(`reads no full source after a signalling refetch of a short anti-joined window`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6653,6 +6705,7 @@ describe(`eager indexed ordered windows`, () => { // loader repairs with its bounded prefix instead; a loader that read the // tie broke the refetch law. it(`repairs instead of reading an unexpressible end's tie after a signalling refetch`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6691,6 +6744,7 @@ describe(`eager indexed ordered windows`, () => { // start no request for the first query and leave it owing no repair: the // window move that follows continues from its boundary instead. it(`starts no request for a window its own first page still proves after a report`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6725,6 +6779,7 @@ describe(`eager indexed ordered windows`, () => { // the end. The loader must read the new end's tie; ignoring the report // made a later window move read the local rows and skip row 11. it(`repairs a signalling refetch before a window move reads past its page`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6761,6 +6816,7 @@ describe(`eager indexed ordered windows`, () => { // the tie request still holds the rank-1 row, so the loader sees no // delete. The report repairs the window. it(`repairs a signalling refetch whose page now ends before its tie`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -6792,6 +6848,7 @@ describe(`eager indexed ordered windows`, () => { // staged continuation after a synchronous answer the graph already // processed closed this trace. it(`shows a peer's full joined window after a refetch removes a tied row`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `refetch`, @@ -6819,6 +6876,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`publishes a full-join window that only loses a row`, async () => { + oracleRoute(`fixed history`) // The delete leaves fewer result rows than the offset, so the window // only loses its row. A subscriber that did not start with that key // receives no removal; this one starts with every visible key and must @@ -6847,6 +6905,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`refills an unindexed on-demand anti-join window after a visible delete`, async () => { + oracleRoute(`fixed history`) // The plan pages without an index; the anti-join drops row 0 after the // provider's limit. Before the compiler counted the rows this window // needs, the window stayed empty after the delete instead of reading the @@ -7010,6 +7069,7 @@ describe(`eager indexed ordered windows`, () => { it.each(optimisticWindowCases)( `characterizes a width-$width $indexed indexed optimistic $shape $mutation from $mutationSource with $extent replacement extent for a $consumer before $outcome ($answerTiming)`, async (testCase) => { + oracleRoute(`case`) const observation = await checkOptimisticWindow(testCase) const actual = optimisticWindowViolations(observation, testCase) // Each legal history must refine the independent public-window model. @@ -7018,6 +7078,7 @@ describe(`eager indexed ordered windows`, () => { ) it(`refills an optimistic rank move that leaves a full installed window`, async () => { + oracleRoute(`fixed history`) // Moving the leading row below a missing source row does not shrink the // installed window. A local row-count check alone cannot discover that // the remote ordered prefix now contains row 1. @@ -7038,6 +7099,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`restores an optimistic membership departure with a rank change on rollback`, async () => { + oracleRoute(`fixed history`) // This is a membership departure, so the short-window law applies even // though its source row also changes order. Rollback must remove both // overlay effects at the same public cut. @@ -7068,6 +7130,7 @@ describe(`eager indexed ordered windows`, () => { )( `publishes an ascending $shape limit-one optimistic delete before $outcome`, async ({ shape, outcome }) => { + oracleRoute(`case`) // Negated ranks make row 3 the ascending head and row 2 its provider // successor. The leading rejected row 4 remains ahead of both. const testCase: OptimisticWindowCase = { @@ -7088,6 +7151,7 @@ describe(`eager indexed ordered windows`, () => { ) it(`rejects a hostile optimistic observation that makes no refill progress`, () => { + oracleRoute(`calibration`) const testCase: OptimisticWindowCase = { shape: `joined`, mutation: `delete`, @@ -7122,6 +7186,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`rejects a delayed Collection publication`, () => { + oracleRoute(`calibration`) const testCase: OptimisticWindowCase = { shape: `joined`, mutation: `update`, @@ -7163,6 +7228,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`rejects a stale publication after transaction settlement`, () => { + oracleRoute(`calibration`) const testCase: OptimisticWindowCase = { shape: `joined`, mutation: `update`, @@ -7205,6 +7271,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`rejects a full-source refill disguised as useful bounded progress`, () => { + oracleRoute(`calibration`) const testCase: OptimisticWindowCase = { shape: `joined`, mutation: `delete`, @@ -7257,6 +7324,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`rejects a source refill that omits eligibility or adds broad work`, async () => { + oracleRoute(`calibration`) const testCase: OptimisticWindowCase = { shape: `source`, mutation: `delete`, @@ -7309,6 +7377,7 @@ describe(`eager indexed ordered windows`, () => { }) it(`rejects acquisition deferred until after optimistic settlement`, async () => { + oracleRoute(`calibration`) const testCase: OptimisticWindowCase = { shape: `source`, mutation: `delete`, @@ -7376,6 +7445,7 @@ describe(`eager indexed ordered windows`, () => { )( `publishes an optimistic departure in an installed $name before $outcome`, async ({ ranks, direction, offset, limit, outcome }) => { + oracleRoute(`case`) const persistence = createDeferred() const rows = ranks.map((rank, index) => ({ id: index + 1, rank })) const source = createCollection( @@ -7443,6 +7513,7 @@ describe(`eager indexed ordered windows`, () => { ] as const)( `shows no rootless full-join row for a null-ranked root past the prefix (%s, %s terms)`, async (path, terms) => { + oracleRoute(`case`) // Nulls last. Row 2 and the marker of row 3 tie on rank in the null // group. A full join reads its full source, so row 3's marker is // matched. A loader that paged this plan and ended its prefix inside @@ -7472,6 +7543,7 @@ describe(`eager indexed ordered windows`, () => { it.each([`sync`, `local-only`] as const)( `shows no rootless full-join row whose root row is past the prefix (%s)`, async (path) => { + oracleRoute(`case`) // Every domain row has a marker, and nulls sort first. A bounded root // prefix of one row would leave the other markers unmatched in the // graph, and their null-ranked rootless rows would take the window @@ -7499,6 +7571,7 @@ describe(`eager indexed ordered windows`, () => { it.each([`sync`, `local-only`] as const)( `refills a joined-filter window when a row leaves only through the join (%s)`, async (path) => { + oracleRoute(`case`) // The bounded prefix holds rows 3 and 1; row 0 then enters the query's // input past it. Row 3 loses its marker without a root delete, so row 0 // must not stand in for row 4, which the prefix never sent. @@ -7532,6 +7605,7 @@ describe(`eager indexed ordered windows`, () => { it.each([`sync`, `local-only`] as const)( `refills a joined-filter window past a retained distant row (%s)`, async (path) => { + oracleRoute(`case`) // A label update brings eligible row 10 into the query's input. After // row 2 leaves, a prefix of one root row (row 1) fails the joined // filter, and row 10 must not stand in for row 4. @@ -7559,6 +7633,7 @@ describe(`eager indexed ordered windows`, () => { it.each([false, true])( `revalidates an empty joined prefix after an ineligible insert (indexed: %s)`, async (indexed) => { + oracleRoute(`case`) await checkEagerWindow({ path: `on-demand`, provider: `refetch-signal`, @@ -7582,17 +7657,26 @@ describe(`eager indexed ordered windows`, () => { }, ) - fcTest.prop([eagerCase], { numRuns: runs, seed: 2044 })( + const eagerCaseFixedSeed = oracleFixedSeed(2044, { numRuns: runs }) + fcTest.prop([eagerCase], eagerCaseFixedSeed)( `keeps rows and per-change work bounded for a fixed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerCaseFixedSeed)) + await checkEagerWindow(scenario) + }, ) - fcTest.prop( - [eagerCase], - oracleRandomParameters(runs, replay, `ordered-work.eager-indexed-window`), - )( + const eagerCaseCampaign = oracleRandomParameters( + runs, + replay, + `ordered-work.eager-indexed-window`, + ) + fcTest.prop([eagerCase], eagerCaseCampaign)( `keeps rows and per-change work bounded for a random or replayed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerCaseCampaign)) + await checkEagerWindow(scenario) + }, ) // Reconstruction: the fixed-seed campaign reaches the premise. Sampling @@ -7601,12 +7685,13 @@ describe(`eager indexed ordered windows`, () => { // windows that hold a rootless row beside a root row with a null rank. const FULL_JOIN_SEED = 2073 it(`generates full-join windows that reach the null group`, () => { + oracleRoute(`fixed seed`) let rootless = 0 let mixedNullGroup = 0 - for (const [testCase] of fc.sample(fc.tuple(eagerFullJoinCase), { - numRuns: runs, - seed: FULL_JOIN_SEED, - })) { + for (const [testCase] of fc.sample( + fc.tuple(eagerFullJoinCase), + oracleFixedSeed(FULL_JOIN_SEED, { numRuns: runs }), + )) { const roots = [ ...fillerRows(testCase.direction, testCase.fillers), ...testCase.initial, @@ -7640,17 +7725,28 @@ describe(`eager indexed ordered windows`, () => { expect(mixedNullGroup).toBeGreaterThanOrEqual(5) }) - fcTest.prop([eagerFullJoinCase], { numRuns: runs, seed: FULL_JOIN_SEED })( + const eagerFullJoinCaseFixedSeed = oracleFixedSeed(FULL_JOIN_SEED, { + numRuns: runs, + }) + fcTest.prop([eagerFullJoinCase], eagerFullJoinCaseFixedSeed)( `keeps a full-join window to the first result rows for a fixed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerFullJoinCaseFixedSeed)) + await checkEagerWindow(scenario) + }, ) - fcTest.prop( - [eagerFullJoinCase], - oracleRandomParameters(runs, replay, `ordered-work.eager-full-join`), - )( + const eagerFullJoinCaseCampaign = oracleRandomParameters( + runs, + replay, + `ordered-work.eager-full-join`, + ) + fcTest.prop([eagerFullJoinCase], eagerFullJoinCaseCampaign)( `keeps a full-join window to the first result rows for a random or replayed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerFullJoinCaseCampaign)) + await checkEagerWindow(scenario) + }, ) // The fixed-seed held campaign and its reconstruction run in one test, so @@ -7662,11 +7758,13 @@ describe(`eager indexed ordered windows`, () => { it(`starts no repair for reports a window's own first page proves for a fixed seed`, async () => { const PROVEN_SEED = 2091 let proven = 0 + const parameters = oracleFixedSeed(PROVEN_SEED, { numRuns: runs }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(eagerProvenReportCase, async (testCase) => { proven += (await runEagerWindow(testCase)).ignoredProvenReports }), - { numRuns: runs, seed: PROVEN_SEED }, + parameters, ) expect(proven, `reports a first page proved`).toBeGreaterThan(0) }) @@ -7680,6 +7778,7 @@ describe(`eager indexed ordered windows`, () => { const reach = createOracleReach(`ordered-work.eager-proven-report`, [ `ignoredProvenReports`, ] as const) + oracleRoute(oracleRouteOf(options)) await fc.assert( fc.asyncProperty(eagerProvenReportCase, async (testCase) => { reach.beginHistory() @@ -7719,6 +7818,13 @@ describe(`eager indexed ordered windows`, () => { [`unindexedRefetchChecks`, `shortUnindexedRefills`] as const, { minimum: 5 }, ) + const campaignParameters = + campaign === `fixed` + ? // This seed kills a loader that repairs every report with a + // full-source read; about half of seeds do. + oracleFixedSeed(2094, { numRuns: runs }) + : oracleRandomParameters(runs, replay, property) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(eagerUnindexedRefetchCase, async (testCase) => { reach.beginHistory() @@ -7730,11 +7836,7 @@ describe(`eager indexed ordered windows`, () => { if (observed[state] > 0) reach.reach(state) } }), - campaign === `fixed` - ? // This seed kills a loader that repairs every report with a - // full-source read; about half of seeds do. - { numRuns: runs, seed: 2094 } - : oracleRandomParameters(runs, replay, property), + campaignParameters, ) reach.assertReached() }) @@ -7747,17 +7849,19 @@ describe(`eager indexed ordered windows`, () => { string, Awaited> >() + const runParameters = oracleFixedSeed(HELD_SEED, { numRuns: runs }) + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty(eagerHeldCase, async (testCase) => { heldReach.set(fc.stringify(testCase), await runEagerWindow(testCase)) }), - { numRuns: runs, seed: HELD_SEED }, + runParameters, ) // Reconstruction: sampling the arbitrary with the same seed and run // count yields the executed cases. const executed = new Set( fc - .sample(eagerHeldCase, { numRuns: runs, seed: HELD_SEED }) + .sample(eagerHeldCase, oracleFixedSeed(HELD_SEED, { numRuns: runs })) .map((testCase) => fc.stringify(testCase)), ) expect( @@ -7789,18 +7893,24 @@ describe(`eager indexed ordered windows`, () => { ).toBeGreaterThanOrEqual(1) }) - fcTest.prop( - [eagerHeldCase], - oracleRandomParameters(runs, replay, `ordered-work.eager-held-load`), - )( + const eagerHeldCaseCampaign = oracleRandomParameters( + runs, + replay, + `ordered-work.eager-held-load`, + ) + fcTest.prop([eagerHeldCase], eagerHeldCaseCampaign)( `keeps a window whose loads answer between live changes for a random or replayed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerHeldCaseCampaign)) + await checkEagerWindow(scenario) + }, ) // A local null-ranked row can appear to fill the window while the settled // provider prefix ends before a better row. Held answers must not let that // local row stand in for source coverage after earlier visible deletes. it(`keeps a joined window within its settled source prefix after held answers`, async () => { + oracleRoute(`fixed history`) await checkEagerWindow({ path: `on-demand`, provider: `forward`, @@ -7836,30 +7946,48 @@ describe(`eager indexed ordered windows`, () => { }) }) - fcTest.prop([eagerJoinedGapCase], { numRuns: runs, seed: 2063 })( + const eagerJoinedGapCaseFixedSeed = oracleFixedSeed(2063, { numRuns: runs }) + fcTest.prop([eagerJoinedGapCase], eagerJoinedGapCaseFixedSeed)( `keeps a joined-filter window whose row leaves only through the join for a fixed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerJoinedGapCaseFixedSeed)) + await checkEagerWindow(scenario) + }, ) - fcTest.prop( - [eagerJoinedGapCase], - oracleRandomParameters(runs, replay, `ordered-work.eager-joined-gap`), - )( + const eagerJoinedGapCaseCampaign = oracleRandomParameters( + runs, + replay, + `ordered-work.eager-joined-gap`, + ) + fcTest.prop([eagerJoinedGapCase], eagerJoinedGapCaseCampaign)( `keeps a joined-filter window whose row leaves only through the join for a random or replayed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerJoinedGapCaseCampaign)) + await checkEagerWindow(scenario) + }, ) - fcTest.prop([eagerJoinedCase], { numRuns: runs, seed: 2059 })( + const eagerJoinedCaseFixedSeed = oracleFixedSeed(2059, { numRuns: runs }) + fcTest.prop([eagerJoinedCase], eagerJoinedCaseFixedSeed)( `keeps a joined-filter window to the first eligible rows for a fixed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerJoinedCaseFixedSeed)) + await checkEagerWindow(scenario) + }, ) - fcTest.prop( - [eagerJoinedCase], - oracleRandomParameters(runs, replay, `ordered-work.eager-joined-window`), - )( + const eagerJoinedCaseCampaign = oracleRandomParameters( + runs, + replay, + `ordered-work.eager-joined-window`, + ) + fcTest.prop([eagerJoinedCase], eagerJoinedCaseCampaign)( `keeps a joined-filter window to the first eligible rows for a random or replayed seed`, - checkEagerWindow, + async (scenario) => { + oracleRoute(oracleRouteOf(eagerJoinedCaseCampaign)) + await checkEagerWindow(scenario) + }, ) }) @@ -7904,6 +8032,16 @@ function expectedInstalledWindow( // are the generators of legal histories: the exhaustive small domain and the // full-join case that the null-group calibration samples. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/local-only.ts#localOnlyCollectionOptions', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/effect.ts#createEffect', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/live/ordered-source-loader.ts', + 'src/query/subset-dedupe.ts', + ], check: [expectEagerWindow, optimisticWindowViolations, assertConsumerParity], model: [ eagerWindow, @@ -7976,6 +8114,7 @@ declareOracle({ it.each(compilerCacheWindows)( `serves every metadata-mode window of one ordered source: %j`, async (history) => { + oracleRoute(`case`) compilerWindowEntered = true compilerWindowReach.beginHistory() const rows = [ @@ -8103,6 +8242,7 @@ afterAll(() => { it.each(joinedPlanHistories)( `retains every joined dependency through ordered plans: %j`, async (history) => { + oracleRoute(`case`) joinedPlanEntered = true joinedPlanReach.beginHistory() const failures: Array = [] diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 77b8d468d6..c3dde7f4c9 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -8,7 +8,10 @@ import { and, eq } from '../../src/query/builder/functions.js' import { PropRef } from '../../src/query/ir.js' import { makeComparator } from '../../src/utils/comparison.js' import { + oracleFixedSeed, oracleRandomParameters, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -53,19 +56,19 @@ import type { * acquisition releases, visible rows, readiness, errors, and every * publication cut. * - * Finite products pin boundary cells; fixed and random fast-check histories + * Finite products cover boundary cells; fixed and random fast-check histories * explore adjacent legal actions. Fault probes establish that wrong order, * false coverage, duplicate work, partial publication, and bad delete payloads * are observable. The model does not infer rows beyond provider evidence. * - * The fixed C-01 through C-09 custom-collation lane declares an independent + * The fixed C-01 through C-09 custom-collation histories declare an independent * rank relation and literal public-key sequences. It crosses ascending and * descending order, scan and auto-index execution, inherited and per-order * configuration, bounded and unbounded queries, a later ordered source that * inherits the leading source's default, an output Collection with a different * downstream default, comparator-equal keys, and hostile provider order. It - * also requires exactly one initial filtered full-source acquisition. This - * lane establishes local custom ordering only; it does not authorize custom + * also requires exactly one initial filtered full-source acquisition. These + * histories establish local custom ordering only; it does not authorize custom * cursor pagination, comparator-aware range predicates, or backend collation * parity. */ @@ -833,6 +836,12 @@ async function runPaginationScenario( * one history, and `referenceWindowRows` predicts each window from the rows. */ declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], check: [runPaginationScenario, runMultiOrderScenario], model: [referenceWindowRows, referenceMultiOrder], }) @@ -2849,6 +2858,7 @@ registerLimitAdmissionOracle() describe(`pagination recomputation oracle`, () => { it(`refills a joined result window through a contract-compliant source`, async () => { + oracleRoute(`fixed history`) type ParentRow = { id: number; rank: number; groupId: number } type ChildRow = { id: number; groupId: number } const parents = [ @@ -2897,6 +2907,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`loads the full ordered source when no continuation index exists`, async () => { + oracleRoute(`fixed history`) type ParentRow = { id: number; rank: number; groupId: number } type ChildRow = { id: number; groupId: number } const parents = [ @@ -2946,6 +2957,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`refines a joined foreign order term through the source tie class`, async () => { + oracleRoute(`fixed history`) type ParentRow = { id: number; sourceRank: number; childId: number } type ChildRow = { id: number; score: number } const parents = [ @@ -2996,6 +3008,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`materializes an empty source window`, async () => { + oracleRoute(`fixed history`) await runPaginationScenario({ ranks: [], direction: `asc`, @@ -3004,6 +3017,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`materializes an offset past the final row`, async () => { + oracleRoute(`fixed history`) await runPaginationScenario({ ranks: [0, 1], direction: `asc`, @@ -3012,6 +3026,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`materializes an initially empty zero-limit window`, async () => { + oracleRoute(`fixed history`) await runPaginationScenario({ ranks: [0, 1, 2], direction: `asc`, @@ -3020,6 +3035,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`rejects the right NaN window members in the wrong order`, () => { + oracleRoute(`calibration`) const rows = [ { id: 1, rank: 10 }, { id: 2, rank: 20 }, @@ -3068,6 +3084,7 @@ describe(`pagination recomputation oracle`, () => { ])( `orders a $direction eager window with a NaN or finite middle rank`, async ({ direction, ranks, expectedIds, limit }) => { + oracleRoute(`case`) const rows = ranks.map((rank, index) => ({ id: index + 1, rank })) const modeled = referenceWindow(rows, direction, { offset: 0, limit }) expect(modeled).toEqual(expectedIds) @@ -3084,6 +3101,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`keeps a NaN window coherent through equal and changed source ranks`, async () => { + oracleRoute(`fixed history`) await runPaginationStateScenario({ ranks: [10, 20, NaN, 30, 40], direction: `desc`, @@ -3099,6 +3117,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`clears and restores a nonempty window across a zero limit`, async () => { + oracleRoute(`fixed history`) await runPaginationScenario({ ranks: [0, 1, 2], direction: `asc`, @@ -3111,6 +3130,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`advances past an implicit public-key tie class`, async () => { + oracleRoute(`fixed history`) await runPaginationScenario({ ranks: [1, 2, 3, 4, 5, 5, 5, 5, 5, 5, 11, 12, 13, 14, 15, 16], direction: `asc`, @@ -3126,6 +3146,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`keeps implicit ties stable across filtered source mutations`, async () => { + oracleRoute(`fixed history`) await runPaginationStateScenario({ ranks: [0, 0, 0, 1, 1, 1], direction: `asc`, @@ -3155,6 +3176,7 @@ describe(`pagination recomputation oracle`, () => { expected: [2], }, ])(`updates a row that $name`, async ({ keeps, action, expected }) => { + oracleRoute(`case`) const scenario: PaginationStateScenario = { ranks: [0, 1], keeps, @@ -3182,6 +3204,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`discovered trace: loads an on-demand window after a zero limit`, async () => { + oracleRoute(`fixed history`) await runOnDemandPaginationScenario({ ranks: [0, 0], direction: `asc`, @@ -3193,6 +3216,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`widens an offset on-demand window after starting at zero limit`, async () => { + oracleRoute(`fixed history`) await runOnDemandPaginationScenario({ ranks: [-1, 0, 0, 0, -1, 0], direction: `asc`, @@ -3206,6 +3230,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`starts an on-demand source prefix after opening a zero window with a local row`, async () => { + oracleRoute(`fixed history`) await runOnDemandPaginationScenario( { ranks: [0, 1], @@ -3235,6 +3260,7 @@ describe(`pagination recomputation oracle`, () => { )( `keeps a failed $direction window private after $replayDelivery source replay until explicit retry`, async ({ direction, replayDelivery }) => { + oracleRoute(`case`) const rows: Array = [ { id: 1, rank: 1 }, { id: 2, rank: 2 }, @@ -3330,6 +3356,7 @@ describe(`pagination recomputation oracle`, () => { ])( `recovers the first $failureKind-rejected ordered request for window $offset:$limit from the full source`, async ({ failureKind, ...window }) => { + oracleRoute(`case`) const authoritativeRows: Array = [ { id: 1, rank: 0 }, { id: 2, rank: 1 }, @@ -3423,6 +3450,7 @@ describe(`pagination recomputation oracle`, () => { it.each([`error`, `AbortError`] as const)( `does not derive a retry cursor from rows written by a %s request`, async (failureKind) => { + oracleRoute(`case`) const authoritativeRows: Array = [ { id: 1, rank: 0 }, { id: 2, rank: 1 }, @@ -3566,6 +3594,7 @@ describe(`pagination recomputation oracle`, () => { ] satisfies ReadonlyArray)( `%s repairs an expressible order with a bounded prefix request`, async (_name, mutation) => { + oracleRoute(`case`) const result = await runPendingMutationScenario( { ranks: [0, 1, 2, 3], @@ -3583,6 +3612,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`finishes bounded repair when a visible delete leaves the source underfilled`, async () => { + oracleRoute(`fixed history`) const result = await runPendingMutationScenario( { ranks: [0, 1, 2], @@ -3611,6 +3641,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`rejects a malformed bounded-repair request trace`, () => { + oracleRoute(`calibration`) const malformed: PendingMutationResult = { mutationRepairRequests: [ { @@ -3639,6 +3670,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`distinguishes a filtered full-source fallback from a repair tie predicate`, () => { + oracleRoute(`fixed history`) const filter = eq(new PropRef([`keep`]), true) const scenario: PendingMutationScenario = { ranks: [0, 1, 2], @@ -3665,6 +3697,7 @@ describe(`pagination recomputation oracle`, () => { it.each([false, true])( `repairs a capped prefix through its first-column tie and cursor refill (explicit key=%s)`, async (explicitPublicKeyOrder) => { + oracleRoute(`case`) const result = await runPendingMutationScenario( { ranks: [0, 1, 1, 2], @@ -3736,6 +3769,7 @@ describe(`pagination recomputation oracle`, () => { )( `holds a $direction page and concurrent live insert when its boundary refinement fails by $delivery`, async ({ direction, delivery }) => { + oracleRoute(`case`) const sign = direction === `asc` ? 1 : -1 const rows: Array = [ { id: 1, rank: sign }, @@ -3833,6 +3867,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`recovers a failed tie boundary from the authoritative full source`, async () => { + oracleRoute(`fixed history`) const authoritativeRows: Array = [ { id: 1, rank: -1 }, // A provider may return equal-order rows in any order. The local public @@ -3932,6 +3967,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`rejects a reentrant window move when an ordered request writes and then throws`, async () => { + oracleRoute(`fixed history`) const authoritativeRows: Array = [ { id: 1, rank: 0 }, { id: 2, rank: 1 }, @@ -4024,6 +4060,7 @@ describe(`pagination recomputation oracle`, () => { )( `rejects a window move reentered from startup request $requestNumber after $delivery delivery`, async ({ delivery, requestNumber }) => { + oracleRoute(`case`) const authoritativeRows: Array = [ { id: 1, rank: 0 }, { id: 2, rank: 1 }, @@ -4104,6 +4141,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`rejects a window move reentered from a public change callback`, async () => { + oracleRoute(`fixed history`) const authoritativeRows: Array = [ { id: 1, rank: 0, keep: true }, { id: 2, rank: 1, keep: true }, @@ -4185,6 +4223,7 @@ describe(`pagination recomputation oracle`, () => { it.each([`return-only`, `write-after-cleanup`])( `rejects a window move when its sync run is cleaned up: %s`, async (delivery) => { + oracleRoute(`case`) const authoritativeRows: Array = [ { id: 1, rank: 0 }, { id: 2, rank: 1 }, @@ -4254,6 +4293,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`tracks an asynchronous cursor continuation after synchronous satisfaction`, async () => { + oracleRoute(`fixed history`) const rows: Array = [ { id: 1, rank: 1 }, { id: 2, rank: 2 }, @@ -4344,6 +4384,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`refines locale-ordered continuations locally when predicate IR cannot express the collation`, async () => { + oracleRoute(`fixed history`) const rows: Array = [ { id: 1, label: `item2` }, { id: 2, label: `item10` }, @@ -4521,30 +4562,38 @@ describe(`pagination recomputation oracle`, () => { ], ] satisfies ReadonlyArray)( `%s`, - async (_name, scenario) => runMultiOrderScenario(scenario), + async (_name, scenario) => { + oracleRoute(`case`) + return runMultiOrderScenario(scenario) + }, ) - fcTest.prop([multiOrderScenarioArbitrary], { + const multiOrderScenarioFixedSeed = oracleFixedSeed(1663, { numRuns: orderedScenarioRuns, - seed: 1663, - })( + }) + fcTest.prop([multiOrderScenarioArbitrary], multiOrderScenarioFixedSeed)( `matches multi-column nullable ordering for a fixed seed`, - runMultiOrderScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(multiOrderScenarioFixedSeed)) + await runMultiOrderScenario(scenario) + }, ) - fcTest.prop( - [multiOrderScenarioArbitrary], - oracleRandomParameters( - orderedScenarioRuns, - replay, - `pagination.multi-order`, - ), - )( + const multiOrderScenarioCampaign = oracleRandomParameters( + orderedScenarioRuns, + replay, + `pagination.multi-order`, + ) + fcTest.prop([multiOrderScenarioArbitrary], multiOrderScenarioCampaign)( `matches multi-column nullable ordering for a random or replayed seed`, - runMultiOrderScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(multiOrderScenarioCampaign)) + await runMultiOrderScenario(scenario) + }, ) it(`continues an indexed multi-column window from its leading boundary`, async () => { + oracleRoute(`fixed history`) const rows: Array = [ { id: 3, primary: 0, secondary: 2 }, { id: 2, primary: 0, secondary: 1 }, @@ -4591,24 +4640,34 @@ describe(`pagination recomputation oracle`, () => { } }) - fcTest.prop([nullableCursorScenarioArbitrary], { + const nullableCursorScenarioFixedSeed = oracleFixedSeed(1665, { numRuns: transitionScenarioRuns, - seed: 1665, - })( + }) + fcTest.prop( + [nullableCursorScenarioArbitrary], + nullableCursorScenarioFixedSeed, + )( `matches nullable cursor ordering while an async response is pending for a fixed seed`, - runNullableCursorScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(nullableCursorScenarioFixedSeed)) + await runNullableCursorScenario(scenario) + }, ) + const nullableCursorScenarioCampaign = oracleRandomParameters( + transitionScenarioRuns, + replay, + `pagination.nullable-cursor`, + ) fcTest.prop( [nullableCursorScenarioArbitrary], - oracleRandomParameters( - transitionScenarioRuns, - replay, - `pagination.nullable-cursor`, - ), + nullableCursorScenarioCampaign, )( `matches nullable cursor ordering while an async response is pending for a random or replayed seed`, - runNullableCursorScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(nullableCursorScenarioCampaign)) + await runNullableCursorScenario(scenario) + }, ) it.each([ @@ -4621,6 +4680,7 @@ describe(`pagination recomputation oracle`, () => { ] satisfies ReadonlyArray)( `%s converges before and after a pending response`, async (_name, mutation) => { + oracleRoute(`case`) const scenario: PendingMutationScenario = { ranks: [0, 1, 2, 3], direction: `asc`, @@ -4663,6 +4723,7 @@ describe(`pagination recomputation oracle`, () => { )( `rejects a reached $fault in a $responseOutcome mutation history`, async ({ responseOutcome, fault, message }) => { + oracleRoute(`case`) const scenario: PendingMutationScenario = { ranks: [0, 1, 2, 3], direction: `asc`, @@ -4685,6 +4746,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`rejects a wrong deleted payload before removing its replica row`, async () => { + oracleRoute(`calibration`) const scenario: PendingMutationScenario = { ranks: [0, 1, 2, 3], direction: `asc`, @@ -4712,6 +4774,7 @@ describe(`pagination recomputation oracle`, () => { ] satisfies ReadonlyArray)( `revalidates a finite ordered prefix after a settled SSE %s`, async (_name, mutation) => { + oracleRoute(`case`) await runPendingMutationScenario( { ranks: [0, 1, 2, 3, 4, 5, 6, 7], @@ -4726,6 +4789,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`retains a finite inactive prefix across shrink, SSE, and re-expansion`, async () => { + oracleRoute(`fixed history`) const rows = new Map( Array.from({ length: 5 }, (_, index) => [ index + 1, @@ -4799,6 +4863,7 @@ describe(`pagination recomputation oracle`, () => { it.each([`asc`, `desc`] as const)( `refreshes from the start when one SSE batch moves the retained prefix (%s)`, async (direction) => { + oracleRoute(`case`) const rows = new Map( Array.from({ length: 6 }, (_, index) => [ index + 1, @@ -4907,6 +4972,7 @@ describe(`pagination recomputation oracle`, () => { >)( `keeps an SSE %s that arrives during boundary refinement`, async (_name, mutation, expectedIds, expectedWideIds) => { + oracleRoute(`case`) const rows = new Map( Array.from({ length: 6 }, (_, index) => [ index + 1, @@ -5055,6 +5121,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`does not use a new row beyond finite coverage as a widening boundary`, async () => { + oracleRoute(`fixed history`) await runPendingMutationScenario( { ranks: [0, 1, 2, 3, 4, 5, 6, 7], @@ -5069,6 +5136,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`discovered trace: a settled rank update refreshes top-k membership`, async () => { + oracleRoute(`fixed history`) const scenario: PendingMutationScenario = { ranks: [0, 0, 1], direction: `desc`, @@ -5080,6 +5148,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`a rejected cursor does not treat a live insert as remote coverage`, async () => { + oracleRoute(`fixed history`) const scenario: PendingMutationScenario = { ranks: [0, -1, 0], direction: `asc`, @@ -5091,12 +5160,16 @@ describe(`pagination recomputation oracle`, () => { await runPendingMutationScenario(scenario, `before-response`) }) - fcTest.prop([pendingMutationScenarioArbitrary, responseTimingArbitrary], { + const pendingMutationScenarioFixedSeed = oracleFixedSeed(1660, { numRuns: transitionScenarioRuns, - seed: 1660, - })( + }) + fcTest.prop( + [pendingMutationScenarioArbitrary, responseTimingArbitrary], + pendingMutationScenarioFixedSeed, + )( `matches recomputation when source mutations cross a pending cursor response for a fixed seed`, async (scenario, timing) => { + oracleRoute(oracleRouteOf(pendingMutationScenarioFixedSeed)) const result = await runPendingMutationScenario(scenario, timing) expectPendingMutationRequestWork(result, scenario) }, @@ -5113,6 +5186,7 @@ describe(`pagination recomputation oracle`, () => { )( `covers pending %s with a %s response %s deterministically`, async (mutationKind, responseOutcome, timing) => { + oracleRoute(`case`) const mutation: PendingMutation = mutationKind === `insert` ? { type: `insert`, row: { id: 5, rank: -1 } } @@ -5132,32 +5206,40 @@ describe(`pagination recomputation oracle`, () => { }, ) + const pendingMutationScenarioCampaign = oracleRandomParameters( + transitionScenarioRuns, + replay, + `pagination.pending-mutation`, + ) fcTest.prop( [pendingMutationScenarioArbitrary, responseTimingArbitrary], - oracleRandomParameters( - transitionScenarioRuns, - replay, - `pagination.pending-mutation`, - ), + pendingMutationScenarioCampaign, )( `matches recomputation when source mutations cross a pending cursor response for a random or replayed seed`, async (scenario, timing) => { + oracleRoute(oracleRouteOf(pendingMutationScenarioCampaign)) const result = await runPendingMutationScenario(scenario, timing) expectPendingMutationRequestWork(result, scenario) }, ) - it( - `discovered trace: retries a rejected cursor after a source and window transition`, - runRejectedCursorRetryAfterMutation, - ) + it(`discovered trace: retries a rejected cursor after a source and window transition`, async () => { + oracleRoute(`fixed history`) + await runRejectedCursorRetryAfterMutation() + }) - fcTest.prop([pendingHistoryScenarioArbitrary], { + const pendingHistoryScenarioFixedSeed = oracleFixedSeed(1664, { numRuns: transitionScenarioRuns, - seed: 1664, - })( + }) + fcTest.prop( + [pendingHistoryScenarioArbitrary], + pendingHistoryScenarioFixedSeed, + )( `matches recomputation across multi-action pending histories for a fixed seed`, - runPendingHistoryScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(pendingHistoryScenarioFixedSeed)) + await runPendingHistoryScenario(scenario) + }, ) it.each([ @@ -5171,6 +5253,7 @@ describe(`pagination recomputation oracle`, () => { ] as const)( `rejects %s in a captured pending history`, async (fault, message) => { + oracleRoute(`calibration`) const scenario: PendingHistoryScenario = { ranks: [0, 0, 1, 2], direction: `asc`, @@ -5187,24 +5270,29 @@ describe(`pagination recomputation oracle`, () => { }, ) + const pendingHistoryScenarioCampaign = oracleRandomParameters( + transitionScenarioRuns, + replay, + `pagination.pending-history`, + ) fcTest.prop( [pendingHistoryScenarioArbitrary], - oracleRandomParameters( - transitionScenarioRuns, - replay, - `pagination.pending-history`, - ), + pendingHistoryScenarioCampaign, )( `matches recomputation across multi-action pending histories for a random or replayed seed`, - runPendingHistoryScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(pendingHistoryScenarioCampaign)) + await runPendingHistoryScenario(scenario) + }, ) - it( - `discovered trace: an in-flight request does not underfill a new window`, - expectInflightRequestFillsNewWindow, - ) + it(`discovered trace: an in-flight request does not underfill a new window`, async () => { + oracleRoute(`fixed history`) + await expectInflightRequestFillsNewWindow() + }) it(`discovered trace: a row moving across an offset window must refill its boundary`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0, 0], direction: `desc`, @@ -5215,6 +5303,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`retains authoritative rows when a later window admits a prior insert`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0], direction: `asc`, @@ -5228,6 +5317,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`restores an out-of-window insert when a later offset selects it`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0], direction: `asc`, @@ -5241,6 +5331,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`restores an out-of-window rank update when a later offset selects it`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0, 0], direction: `asc`, @@ -5254,6 +5345,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`discovered trace: inserting at an empty offset boundary refills the window`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0], direction: `asc`, @@ -5268,6 +5360,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`discovered trace: an insert before a later offset does not skip its new boundary`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [-1, -1, 0, -1, 0, -1, 0, 1, 1], direction: `asc`, @@ -5282,6 +5375,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`discovered trace: an async cursor loads the full offset window`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationScenario = { ranks: [0, 0, 0, 0, 0, 0, 1], direction: `asc`, @@ -5294,6 +5388,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`discovered trace: an async cursor crosses an offset before filling one row`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationScenario = { ranks: [0, 0, -1], direction: `asc`, @@ -5318,6 +5413,7 @@ describe(`pagination recomputation oracle`, () => { )( `bounds requests for an underfilled source: $direction, explicit key=$explicitPublicKeyOrder, filter=$includeFilter`, async (structure) => { + oracleRoute(`case`) await runOnDemandPaginationScenario({ ...structure, ranks: [0, 0], @@ -5339,77 +5435,93 @@ describe(`pagination recomputation oracle`, () => { await runPaginationStateScenario({ ...scenario, ...structure }) } - fcTest.prop([scenarioPayloadArbitrary], { - numRuns: cellRuns, - seed: 16_570 + index, - })(`covers ${name} window paths for a fixed seed`, runWindowCell) fcTest.prop( [scenarioPayloadArbitrary], - oracleRandomParameters( - cellRuns, - replay, - `pagination.matrix.window-${index}`, - ), - )( + oracleFixedSeed(16_570 + index, { numRuns: cellRuns }), + )(`covers ${name} window paths for a fixed seed`, async (scenario) => { + oracleRoute(`fixed seed`) + await runWindowCell(scenario) + }) + const scenarioPayloadCampaign = oracleRandomParameters( + cellRuns, + replay, + `pagination.matrix.window-${index}`, + ) + fcTest.prop([scenarioPayloadArbitrary], scenarioPayloadCampaign)( `covers ${name} window paths for a random or replayed seed [pagination.matrix.window-${index}]`, - runWindowCell, + async (scenario) => { + oracleRoute(oracleRouteOf(scenarioPayloadCampaign)) + await runWindowCell(scenario) + }, ) - fcTest.prop([stateScenarioPayloadArbitrary], { - numRuns: cellRuns, - seed: 16_580 + index, - })(`covers ${name} state paths for a fixed seed`, runStateCell) fcTest.prop( [stateScenarioPayloadArbitrary], - oracleRandomParameters( - cellRuns, - replay, - `pagination.matrix.state-${index}`, - ), - )( + oracleFixedSeed(16_580 + index, { numRuns: cellRuns }), + )(`covers ${name} state paths for a fixed seed`, async (scenario) => { + oracleRoute(`fixed seed`) + await runStateCell(scenario) + }) + const stateScenarioPayloadCampaign = oracleRandomParameters( + cellRuns, + replay, + `pagination.matrix.state-${index}`, + ) + fcTest.prop([stateScenarioPayloadArbitrary], stateScenarioPayloadCampaign)( `covers ${name} state paths for a random or replayed seed [pagination.matrix.state-${index}]`, - runStateCell, + async (scenario) => { + oracleRoute(oracleRouteOf(stateScenarioPayloadCampaign)) + await runStateCell(scenario) + }, ) } - fcTest.prop([scenarioArbitrary], { + const scenarioFixedSeed = oracleFixedSeed(1657, { numRuns: orderedScenarioRuns, - seed: 1657, - })( + }) + fcTest.prop([scenarioArbitrary], scenarioFixedSeed)( `matches full recomputation across ordered windows for a fixed seed`, - runPaginationScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(scenarioFixedSeed)) + await runPaginationScenario(scenario) + }, ) - fcTest.prop( - [scenarioArbitrary], - oracleRandomParameters( - orderedScenarioRuns, - replay, - `pagination.ordered-window`, - ), - )( + const scenarioCampaign = oracleRandomParameters( + orderedScenarioRuns, + replay, + `pagination.ordered-window`, + ) + fcTest.prop([scenarioArbitrary], scenarioCampaign)( `matches full recomputation across ordered windows for a random or replayed seed`, - runPaginationScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(scenarioCampaign)) + await runPaginationScenario(scenario) + }, ) - fcTest.prop([stateScenarioArbitrary], { + const stateScenarioFixedSeed = oracleFixedSeed(1658, { numRuns: transitionScenarioRuns, - seed: 1658, - })( + }) + fcTest.prop([stateScenarioArbitrary], stateScenarioFixedSeed)( `matches full recomputation across source and window transitions for a fixed seed`, - runPaginationStateScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(stateScenarioFixedSeed)) + await runPaginationStateScenario(scenario) + }, ) - fcTest.prop( - [stateScenarioArbitrary], - oracleRandomParameters( - transitionScenarioRuns, - replay, - `pagination.window-transition`, - ), - )( + const stateScenarioCampaign = oracleRandomParameters( + transitionScenarioRuns, + replay, + `pagination.window-transition`, + ) + fcTest.prop([stateScenarioArbitrary], stateScenarioCampaign)( `matches full recomputation across source and window transitions for a random or replayed seed`, - runPaginationStateScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(stateScenarioCampaign)) + await runPaginationStateScenario(scenario) + }, ) it.each( @@ -5428,6 +5540,7 @@ describe(`pagination recomputation oracle`, () => { )( `fetches linear row volume while traversing settled pages: %j`, async ({ direction, mode, explicitPublicKeyOrder, pageSize }) => { + oracleRoute(`case`) const pageCount = 10 const rows = Array.from({ length: pageCount * pageSize }, (_, rank) => ({ id: rank + 1, @@ -5494,6 +5607,7 @@ describe(`pagination recomputation oracle`, () => { )( `refills an ordered window past $count pending optimistic $mutation row(s)`, async ({ mutation, count }) => { + oracleRoute(`case`) const remote = [1, 2, 3, 4, 5].map((id) => ({ id, rank: id })) const delivered = new Set() const requests: Array = [] @@ -5628,6 +5742,7 @@ describe(`pagination recomputation oracle`, () => { )( `does not count $count optimistic $mutation row(s) as $transport provider coverage after a $initialLimit-row window`, async ({ mutation, count, initialLimit, transport }) => { + oracleRoute(`case`) const remote = [1, 2, 3, 4, 5, 6, 7].map((id) => ({ id, rank: id })) const finalLimit = mutation === `insert-before` ? count + 2 : count + 1 const delivered = new Set() @@ -5780,6 +5895,7 @@ describe(`pagination recomputation oracle`, () => { )( `keeps eager window membership when moving past an intervening insert: %j`, async ({ direction, explicitPublicKeyOrder, tied, limit }) => { + oracleRoute(`case`) const sign = direction === `asc` ? 1 : -1 await runPaginationStateScenario({ direction, @@ -5814,6 +5930,7 @@ describe(`pagination recomputation oracle`, () => { )( `keeps live observations separate from a settled acquisition: %j`, async ({ direction, timing, rank, transport }) => { + oracleRoute(`case`) const sign = direction === `asc` ? 1 : -1 await runPendingMutationScenario( { @@ -5832,6 +5949,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`discovered trace: a rank update must refill a top-1 window`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0], direction: `asc`, @@ -5842,6 +5960,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`refills an implicit tie window when a visible row moves below it`, async () => { + oracleRoute(`fixed history`) await runPaginationStateScenario({ ranks: [0, 0, 0, -1, 0], direction: `desc`, @@ -5856,6 +5975,7 @@ describe(`pagination recomputation oracle`, () => { it.each([`resolve`, `reject`] as const)( `keeps the last complete implicit window while background recovery %ss`, async (settlement) => { + oracleRoute(`case`) const authoritativeRows = new Map([ [1, { id: 1, rank: 0, keep: true }], [2, { id: 2, rank: 1, keep: true }], @@ -5991,6 +6111,7 @@ describe(`pagination recomputation oracle`, () => { ] as const)( `refills an implicit %s tie window after a rank update`, async (_name, ranks, initialWindow, id, rank) => { + oracleRoute(`case`) await runPaginationStateScenario({ ranks: [...ranks], direction: `asc`, @@ -6004,6 +6125,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`opens an implicit tie window from zero at the lowest public key`, async () => { + oracleRoute(`fixed history`) await runPaginationStateScenario({ ranks: [0], direction: `asc`, @@ -6020,6 +6142,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`ignores an out-of-window insert when refilling after a delete`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [100, 90, 80, 70], direction: `desc`, @@ -6033,6 +6156,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`ignores an out-of-window rank update when refilling after a delete`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0, 0], direction: `asc`, @@ -6047,6 +6171,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`ignores an out-of-window rank update when the visible row leaves`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0, 0], direction: `asc`, @@ -6061,6 +6186,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`refills untouched rows when widening after an out-of-window rank update`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0, 0], direction: `desc`, @@ -6075,6 +6201,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`rebuilds the full boundary when widening after an out-of-window rank update`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [1, 0, 1, 0, 1], direction: `desc`, @@ -6089,6 +6216,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`ignores an out-of-window insert when widening a tied window`, async () => { + oracleRoute(`fixed history`) const scenario: PaginationStateScenario = { ranks: [0, 0], direction: `asc`, @@ -6102,10 +6230,12 @@ describe(`pagination recomputation oracle`, () => { }) it(`expands a multi-column boundary before choosing top-K`, async () => { + oracleRoute(`fixed history`) await expectMultiOrderBoundaryMatches() }) it(`rejects a real provider entry hidden by a copied pre-cleanup trace`, async () => { + oracleRoute(`calibration`) const scenario = { providerRows: [ { id: 1, rank: 1, label: `first` }, @@ -6124,6 +6254,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`expands a provider tie before applying the public-key tie-breaker`, async () => { + oracleRoute(`fixed history`) const loads = await runAdversarialOrderedProviderScenario({ providerRows: [ { id: 2, rank: 0, label: `second` }, @@ -6141,6 +6272,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`does not derive an ordered boundary from another demand's local row`, async () => { + oracleRoute(`fixed history`) const unrelated = { id: 100, rank: 100, label: `unrelated` } const loads = await runAdversarialOrderedProviderScenario({ providerRows: [ @@ -6159,6 +6291,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`refines an initial locale window without trusting provider collation`, async () => { + oracleRoute(`fixed history`) const loads = await runAdversarialOrderedProviderScenario({ // Lexical provider order disagrees with locale numeric order. providerRows: [ @@ -6238,6 +6371,7 @@ describe(`pagination recomputation oracle`, () => { ] as const)( `uses one filtered full-source acquisition for custom collation ($cell)`, async ({ direction, autoIndex, sourceDefault, bounded, outputDefault }) => { + oracleRoute(`case`) await runCustomCollationScenario({ direction, autoIndex, @@ -6249,6 +6383,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`uses the leading source custom collation when ordering a later source (C-08 multi-source inheritance)`, async () => { + oracleRoute(`fixed history`) type ScopeRow = { id: number; groupId: number } const scope = createCollection( mockSyncCollectionOptions({ @@ -6323,6 +6458,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`inherits collection locale options through scan and auto-index ordering`, async () => { + oracleRoute(`fixed history`) const defaultOnly = createCollection<{ id: number }>({ getKey: (row) => row.id, defaultStringCollation: { stringSort: `locale` }, @@ -6376,6 +6512,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`refines an initial reference-ordered window locally`, async () => { + oracleRoute(`fixed history`) const first = { value: `first` } const second = { value: `second` } // Fix their runtime reference order before the provider returns the @@ -6405,6 +6542,7 @@ describe(`pagination recomputation oracle`, () => { )( `refines invalid Date ties with an unbounded local-order request ($direction, nulls $nulls)`, async ({ direction, nulls }) => { + oracleRoute(`case`) const invalid = new Date(Number.NaN) const loads = await runAdversarialOrderedProviderScenario({ providerRows: [ @@ -6424,6 +6562,7 @@ describe(`pagination recomputation oracle`, () => { ) it(`uses an ascending index for a bounded descending demand`, async () => { + oracleRoute(`fixed history`) const rows: Array = [ { id: 3, rank: 1 }, { id: 1, rank: 0 }, @@ -6502,6 +6641,7 @@ describe(`pagination recomputation oracle`, () => { }) it(`stabilizes an on-demand window with a NaN public-key tie`, async () => { + oracleRoute(`fixed history`) const loads = await runAdversarialOrderedProviderScenario({ providerRows: [ { id: 1, rank: 0, label: `finite` }, @@ -6523,6 +6663,7 @@ describe(`pagination recomputation oracle`, () => { ] as const)( `keeps null placement and $direction across source refinement ($nulls)`, async ({ direction, nulls, expectedIds }) => { + oracleRoute(`case`) const providerRows = [ { id: 1, rank: null, label: `null` }, { id: 2, rank: 0, label: `zero` }, @@ -6541,28 +6682,35 @@ describe(`pagination recomputation oracle`, () => { }, ) - fcTest.prop([scenarioArbitrary], { + const scenarioFixedSeed2 = oracleFixedSeed(1659, { numRuns: transitionScenarioRuns, - seed: 1659, - })( + }) + fcTest.prop([scenarioArbitrary], scenarioFixedSeed2)( `matches full recomputation when exact async cursor loads widen ordered coverage for a fixed seed`, - runOnDemandPaginationScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(scenarioFixedSeed2)) + await runOnDemandPaginationScenario(scenario) + }, ) - fcTest.prop( - [scenarioArbitrary], - oracleRandomParameters( - transitionScenarioRuns, - replay, - `pagination.async-cursor`, - ), - )( + const scenarioCampaign2 = oracleRandomParameters( + transitionScenarioRuns, + replay, + `pagination.async-cursor`, + ) + fcTest.prop([scenarioArbitrary], scenarioCampaign2)( `matches full recomputation when exact async cursor loads widen ordered coverage for a random or replayed seed`, - runOnDemandPaginationScenario, + async (scenario) => { + oracleRoute(oracleRouteOf(scenarioCampaign2)) + await runOnDemandPaginationScenario(scenario) + }, ) it.each([`forward`, `reverse`] as const)( `keeps concurrent on-demand windows correct under %s completion`, - expectOnDemandWindowsAreCompletionOrderIndependent, + async (scenario) => { + oracleRoute(`case`) + await expectOnDemandWindowsAreCompletionOrderIndependent(scenario) + }, ) }) diff --git a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts index d5c1916c5e..bbc119a9e6 100644 --- a/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts +++ b/packages/db/tests/query/pooled-live-query-gc-oracle.test.ts @@ -7,7 +7,13 @@ import { BaseQueryBuilder, Query } from '../../src/query/builder/index.js' import { createLiveQueryCollection, eq } from '../../src/query/index.js' import { createPooledLiveQuery } from '../../src/query/pooled-live-query.js' import { CollectionHasSubscribersError } from '../../src/errors.js' -import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../oracle-config.js' import { createOracleReach } from '../oracle-reach.js' import { declareOracle } from '../oracle-declaration.js' import { @@ -79,6 +85,12 @@ async function expectPooledReleaseMatchesCollection( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/live-query-observer.ts#createLiveQueryObserver', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/pooled-live-query.ts#createPooledLiveQuery', + ], check: [ expectPooledReleaseMatchesCollection, checkCallbackHistory, @@ -334,14 +346,17 @@ describe(`pooled live query gcTime`, () => { ] const reach = createOracleReach(property, states) try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(80) }) + : oraclePropertyOptions(80, property) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(callbackHistory, async (history) => { reach.beginHistory() await checkCallbackHistory(path, boundary, history, reach) }), - campaign === `fixed` - ? { numRuns: oracleRuns(80), seed: 2229 } - : oraclePropertyOptions(80, property), + parameters, ) reach.assertReached() } finally { @@ -370,6 +385,11 @@ describe(`pooled live query gcTime`, () => { `throw`, `undefined-cause`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(40) }) + : oraclePropertyOptions(40, `pooled-live-query.late-cleanup`) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -464,9 +484,7 @@ describe(`pooled live query gcTime`, () => { } }, ), - campaign === `fixed` - ? { numRuns: oracleRuns(40), seed: 2229 } - : oraclePropertyOptions(40, `pooled-live-query.late-cleanup`), + parameters, ) reach.assertReached() console.info( @@ -479,6 +497,7 @@ describe(`pooled live query gcTime`, () => { for (const gcTime of [1, 100, 0, Number.POSITIVE_INFINITY]) { for (const subscribe of [true, false]) { it(`matches a live-query Collection for gcTime ${gcTime}${subscribe ? `` : ` without a subscriber`}`, async () => { + oracleRoute(`case`) await expectPooledReleaseMatchesCollection(gcTime, subscribe) }) } @@ -755,7 +774,7 @@ describe(`pooled live query cleanup admission`, () => { // A view read by a subscribed live query is cleaned up and rejects, then // its unobserved sibling is cleaned up; the reader ends and the view's // cleanup is retried. - const pinned: CleanupHistory = { + const fixedHistory: CleanupHistory = { views: [ { g: `x`, observed: false, reader: true }, { g: `y`, observed: false, reader: false }, @@ -768,20 +787,24 @@ describe(`pooled live query cleanup admission`, () => { ], } it(`keeps a view whose cleanup rejected in its partition`, async () => { - await expectPooledCleanupMatchesOrdinary(pinned) + oracleRoute(`fixed history`) + await expectPooledCleanupMatchesOrdinary(fixedHistory) }) for (const campaign of [`fixed`, `random`] as const) { it(`matches a live-query Collection's cleanup admission (${campaign})`, async () => { const reach = createOracleReach(property, cleanupReachStates) + const campaignParameters = + campaign === `fixed` + ? oracleFixedSeed(2168, { numRuns: oracleRuns(80) }) + : oraclePropertyOptions(80, property) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(cleanupHistory, async (history) => { reach.beginHistory() await expectPooledCleanupMatchesOrdinary(history, reach) }), - campaign === `fixed` - ? { numRuns: oracleRuns(80), seed: 2168 } - : oraclePropertyOptions(80, property), + campaignParameters, ) reach.assertReached() }) 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 1fd1112836..80ad7e1155 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 @@ -63,7 +63,7 @@ * * Calibration: a partition that ignored the previous value, kept group rows * in arrival order, or compared literals without normalization fails the - * pinned histories and campaigns. A view that kept reporting the source's + * fixed histories and campaigns. A view that kept reporting the source's * status, or kept its rows, after cleanup fails the cleanup histories. A * view that empties its groups at cleanup without advancing their revisions * leaves the detached reader on its old rows and fails the cleanup @@ -92,9 +92,12 @@ import { import { createPooledLiveQuery } from '../../src/query/pooled-live-query.js' import { Func, PropRef, Value } from '../../src/query/ir.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -374,9 +377,9 @@ const historyArbitrary: fc.Arbitrary = fc.record({ ), }) -// Each pinned history moves a row across groups a short example would keep +// Each fixed history moves a row across groups a short example would keep // still. -const pinnedHistories: ReadonlyArray<{ name: string; history: History }> = [ +const fixedHistories: ReadonlyArray<{ name: string; history: History }> = [ { name: `a Date row joins the group of its timestamp`, history: { @@ -1059,12 +1062,18 @@ function expectSnapshotMatchesReference( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/live-query-observer.ts#createLiveQueryObserver', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/pooled-live-query.ts#createPooledLiveQuery', + ], check: [runHistory, expectSnapshotMatchesReference], model: [expectedKeys, expectedRows], }) describe(`pooled live query oracle`, () => { - // The pinned histories and the fixed campaign stand aside during any path + // The fixed histories and the fixed-seed campaign stand aside during any path // replay. The random campaign below is the one that a replay of this // property selects. // Only generated histories count toward reach; a replay asserts nothing. @@ -1075,9 +1084,10 @@ describe(`pooled live query oracle`, () => { return runHistory(history, reach) } if (!oracleReplayRequested()) { - // Generated fields are plain values; this pins a field whose getter + // Generated fields are plain values; this covers 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 () => { + oracleRoute(`fixed history`) const source = createCollection( mockSyncCollectionOptions({ id: `pooled-${serial++}`, @@ -1111,14 +1121,22 @@ describe(`pooled live query oracle`, () => { await source.cleanup({ dependents: true }) }) - for (const { name, history } of pinnedHistories) { - it(`matches the live-query Collection when ${name}`, () => - runHistory(history)) + for (const { name, history } of fixedHistories) { + it(`matches the live-query Collection when ${name}`, () => { + oracleRoute(`case`) + return runHistory(history) + }) } - fcTest.prop([historyArbitrary], { - seed: 44_502_001, + const historyFixedSeed = oracleFixedSeed(44_502_001, { numRuns: oracleRuns(80), - })(`matches the live-query Collection (fixed)`, runGenerated) + }) + fcTest.prop([historyArbitrary], historyFixedSeed)( + `matches the live-query Collection (fixed)`, + (history) => { + oracleRoute(oracleRouteOf(historyFixedSeed)) + return runGenerated(history) + }, + ) afterAll(() => { if (generatedRan) { process.stdout.write( @@ -1129,8 +1147,12 @@ describe(`pooled live query oracle`, () => { }) } - oracleReplayTest(fcTest, property).prop( - [historyArbitrary], - oraclePropertyOptions(80, property), - )(`matches the live-query Collection (random)`, runGenerated) + const historyCampaign = oraclePropertyOptions(80, property) + oracleReplayTest(fcTest, property).prop([historyArbitrary], historyCampaign)( + `matches the live-query Collection (random)`, + (history) => { + oracleRoute(oracleRouteOf(historyCampaign)) + return runGenerated(history) + }, + ) }) diff --git a/packages/db/tests/query/public-container-copy-oracle.test.ts b/packages/db/tests/query/public-container-copy-oracle.test.ts index d7dc9a1b77..c35e157639 100644 --- a/packages/db/tests/query/public-container-copy-oracle.test.ts +++ b/packages/db/tests/query/public-container-copy-oracle.test.ts @@ -11,8 +11,11 @@ import { import { materializeCompilation } from '../../src/query/live/materialized-pipeline.js' import { Query, eq, include } from '../../src/query/index.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -86,6 +89,11 @@ function expectCopyFollowsLaw( } declareOracle({ + owns: [ + `src/query/compiler/route-metadata.ts#stripInternalCallbackMetadata`, + `src/query/compiler/route-metadata.ts#transformPublicContainers`, + `src/query/live/materialized-pipeline.ts#compileValueResolver`, + ], model: [expectedCopy], check: [expectCopyFollowsLaw, checkCopyPrograms], grammar: [programs, inputGrammar], @@ -210,7 +218,10 @@ const examples: Array<[CopyInput]> = [ for (const campaign of [ { name: `fixed`, - options: { numRuns: oracleRuns(6) + examples.length, seed: 2355, examples }, + options: oracleFixedSeed(2355, { + numRuns: oracleRuns(6) + examples.length, + examples, + }), }, { name: `fresh or replayed`, @@ -223,6 +234,11 @@ for (const campaign of [ : oracleReplayTest(fcTest, property) register.prop([inputGrammar], campaign.options)( `cleanup preserves authored public copies (${campaign.name})`, - checkCopyPrograms, + (description) => { + // The options are built when the file loads, so each run registers its + // campaign's route. + oracleRoute(oracleRouteOf(campaign.options)) + checkCopyPrograms(description) + }, ) } diff --git a/packages/db/tests/query/subquery-user-value-oracle.test.ts b/packages/db/tests/query/subquery-user-value-oracle.test.ts index 955de9dc5c..663404bbf2 100644 --- a/packages/db/tests/query/subquery-user-value-oracle.test.ts +++ b/packages/db/tests/query/subquery-user-value-oracle.test.ts @@ -10,6 +10,7 @@ import { buildQuery } from '../../src/query/builder/index.js' import { compileQuery } from '../../src/query/compiler/index.js' import { collectCollectionSources } from '../../src/query/ir.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' +import { oracleRoute } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' import { stripVirtualProps } from '../utils.js' import { createControlledCollection } from './includes-oracle-helpers.js' @@ -124,6 +125,11 @@ function expectedFindOneCandidates( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [expectPublicSnapshotFollowsModel], model: [ expectedDistinctRows, @@ -134,6 +140,7 @@ declareOracle({ describe('subquery boundaries preserve operators and user rows', () => { it('joins the distinct subquery result before applying the outer WHERE', async () => { + oracleRoute(`fixed history`) const persons = createControlledCollection( 'distinct-outer-persons', people, @@ -224,6 +231,7 @@ describe('subquery boundaries preserve operators and user rows', () => { }) it('recomputes a joined DISTINCT result after source writes', async () => { + oracleRoute(`fixed history`) const persons = createControlledCollection( 'distinct-history-persons', people, @@ -295,6 +303,7 @@ describe('subquery boundaries preserve operators and user rows', () => { }) it('recomputes a joined aggregate QueryRef after source writes', async () => { + oracleRoute(`fixed history`) const initialItems = [{ id: 1 }, { id: 2 }] const initialPeople = [{ id: 2 }] const source = createControlledCollection( @@ -371,6 +380,7 @@ describe('subquery boundaries preserve operators and user rows', () => { // count as the nested aggregate above. Check the exact public row when // source counts and matching people change. it('publishes a flat aggregate QueryRef join through source writes', async () => { + oracleRoute(`fixed history`) const source = createControlledCollection('flat-aggregate-items', [ { id: 1 }, { id: 2 }, @@ -434,6 +444,7 @@ describe('subquery boundaries preserve operators and user rows', () => { // The public builder documents findOne() as returning the first result. // The nested QueryRef must reduce the candidates before the outer join. it('publishes one ordered joined findOne QueryRef row through source writes', async () => { + oracleRoute(`fixed history`) const initialPeople = [ { id: 1, groupId: 1 }, { id: 2, groupId: 2 }, @@ -539,6 +550,7 @@ describe('subquery boundaries preserve operators and user rows', () => { }) it('materializes one joined findOne value after source writes', async () => { + oracleRoute(`fixed history`) const initialPeople = [ { id: 1, groupId: 1, scope: 1 }, { id: 2, groupId: 2, scope: 1 }, diff --git a/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts index c93714938c..21f4cdda44 100644 --- a/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-effect-oracle.test.ts @@ -57,6 +57,7 @@ import { createTransaction } from '../../src/transactions.js' import { declareOracle } from '../oracle-declaration.js' import { createOracleReach } from '../oracle-reach.js' import { flushPromises, mockSyncCollectionOptions } from '../utils.js' +import { oracleRoute } from '../oracle-config.js' import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' import { checkWithCleanup, @@ -305,6 +306,7 @@ afterAll(() => { test.each(callbackHistories)( `delivers current values across callback profiles: %j`, async (history) => { + oracleRoute(`case`) ranDelivery = true await runCallbackHistory( history, @@ -366,9 +368,9 @@ function sidesOf(history: History): Array { function historyId(history: History): string { return `${pathOf(history)}/confirm-${history.confirm.join(`+`) || `none`}/reject-${history.reject.join(`+`) || `none`}/settle-${history.rightFirst ? `right` : `left`}-first` } -// Declare each finite lane's owed comparisons independently. Aggregate path -// totals would let one omitted right-source action borrow another lane's reach. -// Outcome and confirmation premises determine which cuts are legal in a lane. +// Declare each finite campaign's owed comparisons independently. Aggregate path +// totals would let one omitted right-source action borrow another campaign's reach. +// Outcome and confirmation premises determine which cuts are legal in a campaign. function requiredEffectCuts(history: History): Array { const sides = sidesOf(history) return [ @@ -400,14 +402,14 @@ const effectMetadataReach = createOracleReach( ), ), // This exhaustive grammar has no seed/path replay. An unrelated replay - // environment must not exempt its required finite lanes from reach. + // environment must not exempt its required finite campaigns from reach. { replaying: false }, ) const effectPassedCuts = new Map() let enteredEffectHistories = 0 afterAll(() => { // A checker-only selector enters no production history. Once any native - // history runs, every finite lane still owes its complete declared reach. + // history runs, every finite campaign still owes its complete declared reach. if (enteredEffectHistories > 0) effectMetadataReach.assertReached() if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) console.info( @@ -418,6 +420,11 @@ afterAll(() => { ) }) declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/effect.ts#createEffect', + 'src/transactions.ts#createTransaction', + ], model: [visibleSource, expectedEffectRows, expectedEffectDelta], check: [checkEffectDelta], grammar: [effectMetadataHistories], @@ -570,6 +577,7 @@ function startEffect( test.each(effectMetadataHistories)( `delivers explicitly selected source metadata through Effects: %j`, async (history) => { + oracleRoute(`case`) enteredEffectHistories++ effectMetadataReach.beginHistory() const path = pathOf(history) @@ -824,6 +832,7 @@ declareOracle({ grammar: [effectFaults] }) test.each(effectFaults)( `rejects a hostile Effect metadata ledger: %s`, (fault) => { + oracleRoute(`calibration`) const row = (id: Key, pending: boolean): ProjectedRow => ({ id, side: `left`, @@ -927,6 +936,7 @@ function calibrationLedger( } } test(`permits reordered delivery and fresh opaque keys after an exit`, () => { + oracleRoute(`fixed history`) const before = calibrationRows(false) const after = calibrationRows(true) const active: KeyIntervals = new Map() @@ -949,6 +959,7 @@ test(`permits reordered delivery and fresh opaque keys after an exit`, () => { checkEffectDelta(calibrationLedger(survivor, oldKey), survivor, active) }) test(`permits empty callback partitions while preserving exact delta events`, () => { + oracleRoute(`fixed history`) const before = calibrationRows(false) const after = calibrationRows(true) const active: KeyIntervals = new Map() @@ -965,6 +976,7 @@ test(`permits empty callback partitions while preserving exact delta events`, () } }) test(`records scalar callback values before later object mutation`, () => { + oracleRoute(`fixed history`) const before = calibrationRows(false) const after = calibrationRows(true) const keyFor = (row: ProjectedRow) => occurrence(row) @@ -989,6 +1001,7 @@ test(`records scalar callback values before later object mutation`, () => { checkEffectDelta(ledger, expected, active) }) test(`preserves later interval judgments after an invalid unknown update`, () => { + oracleRoute(`fixed history`) const before = calibrationRows(false) const after = calibrationRows(true) const keyFor = (row: ProjectedRow) => occurrence(row) @@ -1108,6 +1121,7 @@ declareOracle({ model: [boundaryModelRows], grammar: [boundaryHistories] }) test.each(boundaryHistories)( `classifies callback-visible membership across coalesced source histories: %j`, async (history) => { + oracleRoute(`case`) enteredBoundary = true boundaryReach.beginHistory() const main: SourceRow = { diff --git a/packages/db/tests/query/virtual-row-fields-input-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-input-oracle.test.ts index 940bbc282a..a2c0b58fcb 100644 --- a/packages/db/tests/query/virtual-row-fields-input-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-input-oracle.test.ts @@ -32,6 +32,7 @@ import { afterAll, expect, test } from 'vitest' import { declareOracle } from '../oracle-declaration.js' import { createOracleReach } from '../oracle-reach.js' +import { oracleRoute } from '../oracle-config.js' import { expectedMetadataInput, expectedMetadataRecord, @@ -154,6 +155,12 @@ afterAll(() => { } }) declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/effect.ts#createEffect', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [metadataInputViolations, metadataOutputViolations], model: [expectedMetadataInput], grammar: [ @@ -168,6 +175,7 @@ declareOracle({ test.each(callbackCases)( `receives callback metadata before deriving ordinary values: $label`, async (history) => { + oracleRoute(`case`) callbackEntered = true callbackReach.beginHistory() const report = await runMetadataCallbackHistory( @@ -195,6 +203,7 @@ test.each( )( `receives direct source metadata before deriving ordinary values: %j`, async ({ kind, receiver }) => { + oracleRoute(`case`) callbackEntered = true callbackReach.beginHistory() const report = await runMetadataCallbackHistory( @@ -214,6 +223,7 @@ test.each( test.each(inlineCases)( `receives inline authored metadata or rejects compiled Collection input: $label`, async (history) => { + oracleRoute(`case`) inlineEntered = true inlineReach.beginHistory() const report = await runInlineMetadataCallbackHistory( @@ -239,6 +249,7 @@ test.each(inlineCases)( test.each(compilerCases)( `preserves metadata across compiler cache modes: $label`, async (history) => { + oracleRoute(`case`) compilerEntered = true compilerReach.beginHistory() const report = await runMetadataCompilerCacheHistory( @@ -275,6 +286,7 @@ test.each([ `late-capture-rewrite`, `compiler-symbol`, ] as const)(`rejects a hostile callback-input observation: %s`, (fault) => { + oracleRoute(`case`) const description = { shape: `structured-spread`, collision: `all`, @@ -324,6 +336,7 @@ test.each([ `intermediate-sanitizer`, `missing-derived-field`, ] as const)(`rejects a hostile derived ordinary output: %s`, (fault) => { + oracleRoute(`calibration`) const description = { shape: `functional-projection`, collision: `$collectionId`, @@ -352,6 +365,7 @@ test.each([ expect(metadataOutputViolations(hostile, expected)).not.toStrictEqual([]) }) test(`copies callback input before later source objects change`, () => { + oracleRoute(`fixed history`) const description = { shape: `functional-direct`, collision: `none`, @@ -370,6 +384,7 @@ test(`copies callback input before later source objects change`, () => { ).toStrictEqual([]) }) test(`accepts readable inherited source metadata at callback input`, () => { + oracleRoute(`fixed history`) const description = { shape: `functional-direct`, collision: `none`, @@ -390,6 +405,7 @@ test(`accepts readable inherited source metadata at callback input`, () => { ).toStrictEqual([]) }) test(`copies nested callback values before later child objects change`, () => { + oracleRoute(`fixed history`) const child = { ...sourceFact.row, ...expectedSourceVirtualFields(sourceFact), @@ -407,6 +423,7 @@ test(`copies nested callback values before later child objects change`, () => { test.each([`selected-virtual`, `own-undefined`] as const)( `rejects an inherited replacement for an authored inline field: %s`, (fault) => { + oracleRoute(`calibration`) const description = { shape: `functional-projection`, collision: `$collectionId`, @@ -451,6 +468,7 @@ test.each([`selected-virtual`, `own-undefined`] as const)( test.each([`unexpected`, `$collectionId`] as const)( `rejects an extra own field on an inline array: %s`, (field) => { + oracleRoute(`calibration`) const child = expectedMetadataInput(sourceFact, { shape: `functional-projection`, collision: `none`, diff --git a/packages/db/tests/query/virtual-row-fields-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-oracle.test.ts index d181b8d555..05ba36ae13 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -78,6 +78,7 @@ import { BTreeIndex } from '../../src/indexes/btree-index.js' import { declareOracle } from '../oracle-declaration.js' import { createOracleReach } from '../oracle-reach.js' import { mockSyncCollectionOptions } from '../utils.js' +import { oracleRoute } from '../oracle-config.js' import { metadataCollisionCases, metadataCompositions, @@ -146,6 +147,12 @@ function expectPublishedFieldClassification( } declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/virtual-props.ts#hasVirtualProps', + ], check: [ expectPublishedFieldClassification, checkRootMetadata, @@ -513,6 +520,7 @@ declareOracle({ test.each(boundaryPublicationHistories)( `publishes implicit and materialized-input metadata: %j`, async (history) => { + oracleRoute(`case`) boundaryPublicationReach.beginHistory() let parent: AttributionRow = { id: `parent`, @@ -705,6 +713,7 @@ test.each( ({ kind }) => kind === `grouped` || kind === `aggregate`, ), )(`rejects source metadata on a grouped output: %j`, (history) => { + oracleRoute(`calibration`) const parent = { id: `parent`, parentId: `root`, label: `parent` } const child = { id: `child`, parentId: `parent`, label: `held` } const expected = expectedBoundaryPublication( @@ -770,6 +779,7 @@ test.each( composition, customKey, }) => { + oracleRoute(`case`) metadataReach.beginHistory() const parents = createCollection( mockSyncCollectionOptions<{ id: string }>({ @@ -895,9 +905,10 @@ test.each( const pendingKeys = new Set() let finishMutation = async () => {} const check = () => { - // The custom-key lane has a completely independent key prediction. The - // default lane retains the old clean-sibling key comparison only as a - // differential check of undocumented compiler-generated key encoding. + // The custom-key histories have a completely independent key prediction. + // The default-key histories retain the old clean-sibling key comparison + // only as a differential check of undocumented compiler-generated key + // encoding. const outputKey = (id: AttributionKey) => customKey ? `projected-${id}` @@ -990,6 +1001,7 @@ test.each( describe(`virtual row field runtime boundary`, () => { test(`matches the row and value classification after publication`, async () => { + oracleRoute(`fixed history`) const createdAt = new Date(`2026-09-20T12:34:56.000Z`) const rows = createCollection( mockSyncCollectionOptions({ @@ -1191,6 +1203,7 @@ test.each( )( `retains metadata from the last present spread: %j`, async ({ matched, reverse, pendingRight }) => { + oracleRoute(`case`) metadataReach.beginHistory() const left = createCollection( mockSyncCollectionOptions<{ id: string; label: string }>({ @@ -1263,7 +1276,7 @@ test.each( * A held write in either branch must not change the other's metadata. Removing * and reinserting that peer checks retractions and subsequent contributions. * Each checkpoint reuses the authored-facts model; no sibling query supplies - * expected keys in this lane. + * expected keys in these histories. */ const unionHistories = [false, true].flatMap((reverse) => [false, true].flatMap((nested) => @@ -1318,6 +1331,7 @@ test.each(unionHistories)( authoredPending, shape, }) => { + oracleRoute(`case`) metadataReach.beginHistory() unionMetadataReach.beginHistory() const reach = (cut: string) => @@ -1484,6 +1498,7 @@ test.each( ].map((fault) => ({ shape, fault })), ), )(`rejects a hostile complete metadata result: %j`, ({ shape, fault }) => { + oracleRoute(`case`) const keyOf = (id: AttributionKey) => `key-${typeof id}:${id}` const expected = [ ...expectedAttributedRows( @@ -1721,6 +1736,7 @@ declareOracle({ test.each(metadataTimelineHistories)( `publishes metadata through join and write histories: %j`, async ({ shape, reverse, topology, reject, rightFirst }) => { + oracleRoute(`case`) metadataTimelineReach.beginHistory() const reach = (cut: string) => metadataTimelineReach.reach(`${shape}/${topology}/${cut}`) @@ -2024,6 +2040,7 @@ test.each([ `no-callback`, `early-wrong-later-correct`, ] as const)(`rejects a hostile metadata publication: %s`, (fault) => { + oracleRoute(`case`) const base: AttributionRow = { id: `a`, parentId: `parent`, label: `before` } const before = expectedAttributedRows( [base], @@ -2185,6 +2202,7 @@ test.each(facadeMetadataHistories)( destinationPopulated, reject, }) => { + oracleRoute(`case`) facadeMetadataReach.beginHistory() const reach = (cut: string) => facadeMetadataReach.reach(`${shape}/${cut}`) let release!: () => void @@ -2406,6 +2424,7 @@ test.each(facadeMetadataHistories)( test.each([`none`, `$key`, `$hasPendingWrites`, `all`] as const)( `rejects facade enrichment of an inline value: %s`, (collision) => { + oracleRoute(`calibration`) const overrides: Record = {} for (const field of virtualFieldNames) if (collision === field || collision === `all`) @@ -2503,6 +2522,7 @@ declareOracle({ test.each(nullableFacadeHistories)( `publishes nullable whole rows through a facade: %j`, async ({ initialMatch }) => { + oracleRoute(`case`) nullableFacadeReach.beginHistory() const children = createCollection( mockSyncCollectionOptions<{ id: string }>({ @@ -2587,6 +2607,7 @@ test.each([ `empty-inline-enriched`, `matched-field-missing`, ] as const)(`rejects a hostile nullable whole-row observation: %s`, (fault) => { + oracleRoute(`calibration`) const expected = expectedNullableSelection( fault === `matched-field-missing` ? { id: `child`, label: `source` } diff --git a/packages/db/tests/query/virtual-row-fields-types-oracle.test.ts b/packages/db/tests/query/virtual-row-fields-types-oracle.test.ts index d1328a66de..f942a78ac9 100644 --- a/packages/db/tests/query/virtual-row-fields-types-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-types-oracle.test.ts @@ -34,6 +34,7 @@ import { afterAll, expect, test } from 'vitest' import * as publicDb from '../../src/index.js' import { declareOracle } from '../oracle-declaration.js' import { createOracleReach } from '../oracle-reach.js' +import { oracleRoute } from '../oracle-config.js' import { checkWithCleanup } from './virtual-row-fields-support.js' import { metadataCompositions as compositions, @@ -912,6 +913,7 @@ function checkConsumer(diagnostics: ReadonlyArray): void { ).toBe(``) } declareOracle({ + owns: ['src/index.ts'], grammar: [ typeShapes, authoredCollisions, @@ -930,6 +932,7 @@ declareOracle({ test.each(typeShapes)( `preserves generated public row/value types: %j`, async (shape) => { + oracleRoute(`case`) publicTypesEntered = true reach.beginHistory() const consumer = consumerSource(shape) @@ -956,6 +959,7 @@ test.each([ ] as const)( `rejects a hostile public type observation: %s`, (fault) => { + oracleRoute(`case`) const consumer = consumerSource( fault === `one-branch-readonly` ? typeShapes[1]! : typeShapes[0]!, new Set([ @@ -1029,6 +1033,7 @@ test.each([ test.each([`non-record-facade`, `missing-index-type`] as const)( `rejects an illegal generated construction at admission: %s`, async (fault) => { + oracleRoute(`calibration`) const consumer = consumerSource( typeShapes[0]!, new Set([`inline/array/toArray`]), 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 7e5c1ed157..7606e81363 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 @@ -64,8 +64,8 @@ * publication, and a restarted source) compare with written values, so they * are unit tests in where-predicate-publication.test.ts. * - * Calibration: pinned histories separate Kleene from two-valued logic, and a - * checker test requires the two-valued answer to fail. Hostile production + * Calibration: fixed histories separate Kleene from two-valued logic, and a + * checker test requires the two-valued answer to fail. Production * mutants that returned FALSE for `eq(string, null)`, prefiltered on `or` * operands as if they were conjuncts, prefiltered number literals past Date or * NaN rows, skipped an empty @@ -89,9 +89,12 @@ import { BasicIndex } from '../../src/indexes/basic-index.js' import { and, eq, not, or } from '../../src/query/builder/functions.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../oracle-config.js' import { declareOracle } from '../oracle-declaration.js' @@ -369,7 +372,7 @@ const historyArbitrary: fc.Arbitrary = fc.oneof( // Kleene and two-valued logic disagree on each snapshot history. The first is // the negated nullish comparison that a FALSE-for-UNKNOWN fast path misses. -const pinnedHistories: ReadonlyArray = [ +const fixedHistories: ReadonlyArray = [ { kind: `snapshot`, rows: [ @@ -428,7 +431,7 @@ const pinnedHistories: ReadonlyArray = [ // An unindexed snapshot may reject stored rows by one field before copying // them. A pending optimistic row is visible although it is not yet synced. -const pinnedPrefilteredSnapshot: History = { +const fixedPrefilteredSnapshot: History = { kind: `snapshot`, rows: [ { v: `a`, optimistic: true }, @@ -440,7 +443,7 @@ const pinnedPrefilteredSnapshot: History = { // Each change history moves rows across a predicate whose equality operand a // skip-if-no-match shortcut can misread. -const pinnedChangeHistories: ReadonlyArray = [ +const fixedChangeHistories: ReadonlyArray = [ { // `or` operands are alternatives, not conjuncts. kind: `changes`, @@ -792,10 +795,15 @@ async function runHistory(history: History): Promise { // BasicIndex, and compares every consumer's observed key set with the model's // keys after each checkpoint. The model is `expectedTruth`, the Kleene // evaluator, and `expectedVisible`, which adds the touched-row rule for a -// subscriber without initial state. The pinned calibration and the generated +// subscriber without initial state. The fixed calibration and the generated // properties reach them; the witness tests in where-predicate-publication.test.ts // do not. declareOracle({ + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], check: [runHistory], model: [expectedTruth, expectedVisible], }) @@ -830,13 +838,14 @@ function describePredicate(predicate: Predicate): string { // --------------------------------------------------------------------------- describe(`WHERE predicate publication oracle`, () => { - // The pinned histories and the fixed campaign stand aside during any path + // The fixed histories and the fixed-seed 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`, () => { + it(`fixed snapshot histories distinguish Kleene logic from two-valued logic`, () => { + oracleRoute(`fixed history`) // A checker that folds UNKNOWN into FALSE before NOT must disagree with - // the model on each pinned snapshot, or the history does not calibrate. + // the model on each fixed snapshot, or the history does not calibrate. const twoValued = (predicate: Predicate, row: ModelRow): boolean => { switch (predicate.kind) { case `eq`: @@ -854,7 +863,7 @@ describe(`WHERE predicate publication oracle`, () => { return predicate.args.some((arg) => twoValued(arg, row)) } } - for (const history of pinnedHistories) { + for (const history of fixedHistories) { if (history.kind !== `snapshot`) throw new Error(`expected a snapshot`) const rows = new Map( history.rows.map((seed, index): [string, ModelRow] => [ @@ -875,22 +884,34 @@ describe(`WHERE predicate publication oracle`, () => { }) for (const history of [ - ...pinnedHistories, - pinnedPrefilteredSnapshot, - ...pinnedChangeHistories, + ...fixedHistories, + fixedPrefilteredSnapshot, + ...fixedChangeHistories, ]) { - it(`publishes TRUE rows for ${history.kind} history ${describePredicate(history.predicate)}`, () => - runHistory(history)) + it(`publishes TRUE rows for ${history.kind} history ${describePredicate(history.predicate)}`, () => { + oracleRoute(`case`) + return runHistory(history) + }) } - fcTest.prop([historyArbitrary], { - seed: 44_500_301, + const historyFixedSeed = oracleFixedSeed(44_500_301, { numRuns: oracleRuns(80), - })(`publishes exactly the TRUE rows (fixed)`, runHistory) + }) + fcTest.prop([historyArbitrary], historyFixedSeed)( + `publishes exactly the TRUE rows (fixed)`, + (history) => { + oracleRoute(oracleRouteOf(historyFixedSeed)) + return runHistory(history) + }, + ) } - oracleReplayTest(fcTest, property).prop( - [historyArbitrary], - oraclePropertyOptions(80, property), - )(`publishes exactly the TRUE rows (random)`, runHistory) + const historyCampaign = oraclePropertyOptions(80, property) + oracleReplayTest(fcTest, property).prop([historyArbitrary], historyCampaign)( + `publishes exactly the TRUE rows (random)`, + (history) => { + oracleRoute(oracleRouteOf(historyCampaign)) + return runHistory(history) + }, + ) }) diff --git a/packages/db/tests/replay-reporter-format.fixture.ts b/packages/db/tests/replay-reporter-format.fixture.ts new file mode 100644 index 0000000000..8e725ac3b1 --- /dev/null +++ b/packages/db/tests/replay-reporter-format.fixture.ts @@ -0,0 +1,62 @@ +// A child process for `oracle-replay.test.ts`: it runs one async property +// failure without and with the guarded replay reporter, and prints what the +// parent compares. The framework reads the replay environment once, when the +// oracle configuration loads, so the parent sets that environment before this +// process starts; a test cannot change it afterwards. +import { readFileSync } from 'node:fs' +import fc from 'fast-check' +import { oracleReplayReporter } from './oracle-replay-witness.js' + +const property = `oracle-replay.calibration` +const sentinel = new Error(`native cause sentinel`) +const value = { + [fc.asyncToStringMethod]: () => Promise.resolve(`async replay value`), +} +const failing = fc.asyncProperty(fc.constant(value), () => + Promise.reject(sentinel), +) + +async function failure(instrumented: boolean): Promise { + try { + await fc.assert(failing, { + seed: 42, + path: `0`, + numRuns: 1, + ...(instrumented ? oracleReplayReporter(property, 42, `0`) : {}), + }) + } catch (error) { + if (error instanceof Error) return error + throw error + } + throw new Error(`expected property failure`) +} + +/** The `cause` property's attributes, and whether it holds the sentinel. */ +function causeOf(error: Error) { + const descriptor = Object.getOwnPropertyDescriptor(error, `cause`) + return descriptor === undefined + ? undefined + : { + writable: descriptor.writable, + enumerable: descriptor.enumerable, + configurable: descriptor.configurable, + sentinel: descriptor.value === sentinel, + } +} + +const native = await failure(false) +const instrumented = await failure(true) +const channel = process.env.TANSTACK_DB_ORACLE_REPLAY_WITNESS +console.log( + JSON.stringify({ + native: { message: native.message, cause: causeOf(native) }, + instrumented: { + message: instrumented.message, + cause: causeOf(instrumented), + }, + witness: + channel === undefined + ? undefined + : JSON.parse(readFileSync(channel, `utf8`)), + }), +) diff --git a/packages/db/tests/request-errors-work.test.ts b/packages/db/tests/request-errors-work.test.ts index 8fb69fb4fc..78063c8c6f 100644 --- a/packages/db/tests/request-errors-work.test.ts +++ b/packages/db/tests/request-errors-work.test.ts @@ -3,7 +3,7 @@ import { RequestErrors } from '../src/collection/lifecycle.js' /** * Work witness for request-error bookkeeping. The request error bookkeeping - * lane of the source-health oracle owns what a node shows; this file only + * grammar of the source-health oracle owns what a node shows; this file only * checks what it costs. * * A failed lazy segment reports one request error per needed key, all with diff --git a/packages/db/tests/reverse-index-oracle.property.test.ts b/packages/db/tests/reverse-index-oracle.property.test.ts index e5873bc89a..c98ece4e42 100644 --- a/packages/db/tests/reverse-index-oracle.property.test.ts +++ b/packages/db/tests/reverse-index-oracle.property.test.ts @@ -3,7 +3,13 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { BTreeIndex } from '../src/indexes/btree-index.js' import { ReverseIndex } from '../src/indexes/reverse-index.js' import { PropRef } from '../src/query/ir.js' -import { oracleRandomParameters, readOracleRunConfig } from './oracle-config.js' +import { + oracleFixedSeed, + oracleRandomParameters, + oracleRoute, + oracleRouteOf, + readOracleRunConfig, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' /** @@ -43,7 +49,7 @@ import { declareOracle } from './oracle-declaration.js' * is the reader `@tanstack/db` exported before query-aware placement. It * reads the original index's reversed walk unchanged, so its nullish group * sits wherever plain reversal puts it, for BTree and Basic indexes and for - * either null placement of the original. The pinned keys below are the + * either null placement of the original. The fixed keys below are the * results of the earlier release. * - **Capability:** ordered-query admission requires only range operations * (`supports('gt')`). The reader may use the `equalityLookup` every index @@ -172,7 +178,10 @@ const readCase: fc.Arbitrary = fc.record({ rejected: fc.uniqueArray(fc.integer({ min: 0, max: 15 }), { maxLength: 6 }), }) +// What this oracle owns: the gap report judges every function in +// reverse-index.ts by this file's tests. declareOracle({ + owns: [`src/indexes/reverse-index.ts`], check: [checkRead], model: [modelOrder, after, expectedRead], }) @@ -184,6 +193,7 @@ describe(`reversed index reads`, () => { it.each([`first`, `last`] as const)( `reads 50 keys among 5,000 nullish keys with nulls %s in bounded non-null work`, (nulls) => { + oracleRoute(`case`) const entries: Array = Array.from({ length: 10_000 }, (_, key) => [ key, key % 2 === 0 ? null : key, @@ -200,16 +210,25 @@ describe(`reversed index reads`, () => { }, ) - fcTest.prop([readCase], { numRuns: runs, seed: 2045 })( + const readCaseFixedSeed = oracleFixedSeed(2045, { numRuns: runs }) + fcTest.prop([readCase], readCaseFixedSeed)( `matches the query order in bounded work for a fixed seed`, - checkRead, + (read) => { + oracleRoute(oracleRouteOf(readCaseFixedSeed)) + checkRead(read) + }, ) - fcTest.prop( - [readCase], - oracleRandomParameters(runs, replay, `reverse-index.reads`), - )( + const readCaseCampaign = oracleRandomParameters( + runs, + replay, + `reverse-index.reads`, + ) + fcTest.prop([readCase], readCaseCampaign)( `matches the query order in bounded work for a random or replayed seed`, - checkRead, + (read) => { + oracleRoute(oracleRouteOf(readCaseCampaign)) + checkRead(read) + }, ) }) diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index f3db44cca9..39ebb750a6 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -186,24 +186,24 @@ * announced, and that each `status:change` is followed at once by * `health:change`. When the expected tree changed, the last event must show * the current tree. `findProblems` must list, in tree order, exactly the - * expected nodes with a problem of their own. Request lanes check that a lazy load or a root Collection's + * expected nodes with a problem of their own. Request grammars check that a lazy load or a root Collection's * load that fails after initial-query readiness is a request error on the query's - * node, and that it clears. Effect lanes check the Effect's gate, and that a + * node, and that it clears. Effect grammars check the Effect's gate, and that a * failed load after it still ends the Effect: an Effect has no request errors. - * The on-demand reload lane checks that a reload of an on-demand Collection + * The on-demand reload grammar checks that a reload of an on-demand Collection * keeps a query's rows until the new run reloads its subset. * * ## Limits * * The main grammar's Collections are eager, so a live query cannot become - * ready while a Collection is still loading; a focused lane enumerates an + * ready while a Collection is still loading; a focused grammar enumerates an * on-demand Collection that fails its first load after the query is ready. * The main grammar reaches a cleanup during a reload only from `health:change` * and only with `{ dependents: true }`; the lazy grammar's `ending` reaches * every notification and also a plain `cleanup()`, but only as a history's * last step, and it does not judge the query a plain cleanup leaves. * The window grammar settles all pending loads at once, and the lazy grammar - * settles all, the oldest, or the newest (the overlapping-load lane owns + * settles all, the oldest, or the newest (the overlapping-load grammar owns * other orders). A demand parked behind a child whose first load failed makes * no request, so the model predicts it from law 10 (maintainer decision). Per-row child Collections are not * in the tree; an include's node shows its source. Pooled histories do not @@ -216,7 +216,7 @@ * it has no truncate, so a truncate replay that would wait for an aborted * load belongs to the subscription lifecycle oracle (its `manual` * cancellation). The shared demand model lets a held load's success clear - * its keys' errors whenever it started; the overlapping-load lane owns the + * its keys' errors whenever it started; the overlapping-load grammar owns the * rule that a root Collection's request error clears only for a load * started after the failure. */ @@ -237,9 +237,12 @@ import { RequestErrors } from '../src/collection/lifecycle.js' import { createDeferred } from '../src/deferred.js' import { createOracleReach } from './oracle-reach.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' @@ -261,7 +264,15 @@ import type { // The model steps each Collection and derives the trees, rows, request errors, // and gates that the public observations must equal; the checks compare them. +// What this oracle owns: the gap report judges the Collection's status and +// health tree, its request errors, and the demand that loads its rows, by this +// file's tests. declareOracle({ + owns: [ + `src/collection/health-tree.ts`, + `src/collection/lifecycle.ts`, + `src/collection/subscription.ts`, + ], check: [expectIdentity, expectRequestErrors, expectHeldDemandCovered], model: [ applyModel, @@ -372,7 +383,7 @@ type Step = // The main grammar reaches the first two; the lazy grammar reaches all four. // Not reached: the retired run's adapter cleanup function and the new run's // `sync()` are adapter code, and a cleanup from them is a cleanup during sync -// entry or adapter cleanup, which other lanes own. +// entry or adapter cleanup, which other grammars own. type ReloadNotice = `source-health` | `query-health` | `loading-subset` | `subscription-status` @@ -3128,7 +3139,10 @@ const it = oracleReplayTest(vitestIt) describe(`source failure and health oracle`, () => { for (const shape of shapes) { for (const [name, steps] of Object.entries(namedHistories)) { - it(`${shape}: ${name}`, () => runHistory(shape, steps)) + it(`${shape}: ${name}`, () => { + oracleRoute(`fixed history`) + return runHistory(shape, steps) + }) } } @@ -3366,6 +3380,7 @@ describe(`source failure and health oracle`, () => { historyReachStates, ) let historiesRan = false + const randomCampaign = oraclePropertyOptions(60, `source-health.history`) oracleReplayTest(fcTest, `source-health.history`).prop( [ fc @@ -3380,10 +3395,11 @@ describe(`source failure and health oracle`, () => { ]), fc.boolean(), ], - oraclePropertyOptions(60, `source-health.history`), + randomCampaign, )( `generated histories refine the model`, ([shape, steps], directSubscribers) => { + oracleRoute(oracleRouteOf(randomCampaign)) historiesRan = true historyReach.beginHistory() return withDeadline( @@ -3402,7 +3418,7 @@ describe(`source failure and health oracle`, () => { // retain all six shapes and both direct-subscriber choices above. const requiredProperty = `source-health.required-history` const config = readOracleRunConfig() - for (const seed of config.replaySeed === undefined + for (const seed of config.campaignSeed === undefined ? [20261029, undefined] : [undefined]) { const requiredReach = createOracleReach( @@ -3411,15 +3427,17 @@ describe(`source failure and health oracle`, () => { { replaying: oracleReplayRequested() }, ) let requiredRan = false + const randomCampaign2 = { + ...oraclePropertyOptions(6, requiredProperty), + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } oracleReplayTest(fcTest, requiredProperty).prop( [fc.array(stepArb, { maxLength: 12 })], - { - ...oraclePropertyOptions(6, requiredProperty), - ...(seed === undefined ? {} : { seed }), - }, + randomCampaign2, )( `required histories refine the model (${seed ?? `random`})`, async (rest) => { + oracleRoute(oracleRouteOf(randomCampaign2)) requiredRan = true historiesRan = true for (const prefix of reachPrefixes) { @@ -3487,6 +3505,7 @@ describe(`overlapping root loads`, () => { it.each(histories)( `outcomes $outcomes, load $first settles first`, async (history) => { + oracleRoute(`case`) const loads: Array>> = [] let deferLoads = false let params!: SyncParams @@ -3582,7 +3601,7 @@ describe(`overlapping root loads`, () => { }) // --------------------------------------------------------------------------- -// On-demand reload lane +// On-demand reload grammar // --------------------------------------------------------------------------- /** @@ -3647,6 +3666,7 @@ describe(`on-demand reload`, () => { it.each(histories)( `first commit $firstCommit, query $query`, async (history) => { + oracleRoute(`case`) let runs = 0 const runLoads: Array void>> = [[], [], []] const newRunReady = createDeferred() @@ -4471,7 +4491,7 @@ function expectHeldDemandCovered( * window. The query shows two rows ordered by id. Steps: `window` moves the * window two rows on; `settle-ok` settles every pending load, and any load * the query requests after it, until none is pending; `settle-fail` rejects - * every pending load at once (the overlapping-load lane owns the order of + * every pending load at once (the overlapping-load grammar owns the order of * two loads); `settle-aborted-fail` rejects only the loads the query aborted, * with an `AbortError`, as an adapter that honours the signal does, and * `settle-aborted-ok` serves only those loads late, as an adapter whose @@ -5229,7 +5249,10 @@ describe(`on-demand demand grammar`, () => { `settle-ok`, ] for (const [name, steps] of Object.entries(named)) - it(name, () => runDemandHistory(steps)) + it(name, () => { + oracleRoute(`fixed history`) + return runDemandHistory(steps) + }) const step = fc.constantFrom( `window`, @@ -5306,6 +5329,9 @@ describe(`on-demand demand grammar`, () => { afterAll(() => { if (demandHistoriesRan) demandReach.assertReached() }) + const randomCampaign3 = oraclePropertyOptions(60, `source-health.on-demand`, { + examples: demandExamples, + }) oracleReplayTest(fcTest, `source-health.on-demand`).prop( [ fc @@ -5319,12 +5345,11 @@ describe(`on-demand demand grammar`, () => { ]), fc.boolean(), ], - oraclePropertyOptions(60, `source-health.on-demand`, { - examples: demandExamples, - }), + randomCampaign3, )( `generated demand histories refine the model`, (steps, directSubscriber) => { + oracleRoute(oracleRouteOf(randomCampaign3)) demandHistoriesRan = true // fast-check passes an example's input by reference. demandReach.beginHistory({ @@ -5545,7 +5570,7 @@ type LazyReach = ReturnType< // refuses while an Effect reads the child): the child ends `cleaned-up` with no sync // run live at its adapter and the reload settled; a cascade also ends the // query and the Effect. A plain cleanup under the query is a query failure -// that this lane does not judge. Then a fresh start commits one row, and +// that this grammar does not judge. Then a fresh start commits one row, and // that row is both the child's rows and its diff baseline: nothing of the // reload stayed armed. type LazyEnding = { at: ReloadNotice; dependents: boolean } @@ -5567,7 +5592,7 @@ async function runLazyHistory( // The query orders parents by id and shows the first `window`. window?: number // By step index, the keys of each request the adapter receives in that - // step. A named history pins production's requests where a parked + // step. A fixed history asserts production's requests where a parked // prediction rests on them. requests?: Readonly>>> asyncChildReady?: boolean @@ -6292,11 +6317,15 @@ describe(`lazy join demand grammar`, () => { ], } for (const [name, steps] of Object.entries(named)) - it(name, () => runLazyHistory(steps)) + it(name, () => { + oracleRoute(`fixed history`) + return runLazyHistory(steps) + }) // A churn replay request fails, then a demand change releases it while it // still shares a key with a replay load that later fails too. - it(`a failed replay does not list a load the query released before its verdict`, () => - runLazyHistory([ + it(`a failed replay does not list a load the query released before its verdict`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, @@ -6311,46 +6340,58 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, { kind: `settle-fail` }, - ])) - it(`a demand change that asks for a failed key again keeps its replay open`, () => - runLazyHistory([ + ]) + }) + it(`a demand change that asks for a failed key again keeps its replay open`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `reload` }, { kind: `parent`, key: 2 }, { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, - ])) - it(`a first load waits for a replay load that no demand change follows`, () => - runLazyHistory([ + ]) + }) + it(`a first load waits for a replay load that no demand change follows`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `parent`, key: 3 }, { kind: `settle-first-ok` }, { kind: `reload` }, { kind: `unparent`, key: 3 }, - ])) - it(`a demand change after a failed replay load requests its key again`, () => - runLazyHistory([ + ]) + }) + it(`a demand change after a failed replay load requests its key again`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `parent`, key: 2 }, { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, - ])) - it(`a reload's retired load decides nothing when its abort rejects it`, () => - runLazyHistory([ + ]) + }) + it(`a reload's retired load decides nothing when its abort rejects it`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `reload` }, { kind: `settle-last-ok` }, { kind: `settle-first-fail` }, - ])) - it(`a later load of the failed key clears its error while another load is pending`, () => - runLazyHistory([ + ]) + }) + it(`a later load of the failed key clears its error while another load is pending`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `unparent`, key: 1 }, { kind: `parent`, key: 1 }, { kind: `parent`, key: 2 }, { kind: `settle-first-fail` }, { kind: `parent`, key: 3 }, { kind: `settle-last-ok` }, - ])) - it(`a demand change aborts a replay load and must request its still-held key again`, () => - runLazyHistory( + ]) + }) + it(`a demand change aborts a replay load and must request its still-held key again`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, @@ -6361,12 +6402,14 @@ describe(`lazy join demand grammar`, () => { { kind: `parent`, key: 1 }, ], { holdChildReady: true }, - )) + ) + }) // Neighbours of the parked-join history: the parked demand is requested // when its source restarts, and the replay then settles as usual; the // parked demand retires instead, which aborts the replay. - it(`a parked demand loads when its source restarts, and the replay then settles`, () => - runLazyHistory( + it(`a parked demand loads when its source restarts, and the replay then settles`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, @@ -6379,9 +6422,11 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-ok` }, ], { holdChildReady: true }, - )) - it(`a parked replay ends when its last demand retires`, () => - runLazyHistory( + ) + }) + it(`a parked replay ends when its last demand retires`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, @@ -6394,35 +6439,43 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 2 }, ], { holdChildReady: true }, - )) - it(`a load that fails during a replay is decided by the replay's verdict`, () => - runLazyHistory([ + ) + }) + it(`a load that fails during a replay is decided by the replay's verdict`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `parent`, key: 3 }, { kind: `settle-last-fail` }, { kind: `settle-first-fail` }, { kind: `unparent`, key: 3 }, - ])) - it(`a reload during the first load fails it only for a demand still held`, () => - runLazyHistory([ + ]) + }) + it(`a reload during the first load fails it only for a demand still held`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `parent`, key: 3 }, { kind: `reload` }, { kind: `settle-first-fail` }, { kind: `parent`, key: 2 }, { kind: `unparent`, key: 3 }, { kind: `unparent`, key: 2 }, - ])) - it(`a second reload replaces a load that failed during the first replay`, () => - runLazyHistory([ + ]) + }) + it(`a second reload replaces a load that failed during the first replay`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `reload` }, { kind: `parent`, key: 3 }, { kind: `settle-last-fail` }, { kind: `reload` }, { kind: `settle-ok` }, - ])) - it(`a second reload's replay replaces the first replay's loads`, () => - runLazyHistory([ + ]) + }) + it(`a second reload's replay replaces the first replay's loads`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `parent`, key: 2 }, { kind: `reload` }, { kind: `reload` }, @@ -6430,26 +6483,32 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-first-ok` }, { kind: `settle-last-ok` }, { kind: `settle-first-fail` }, - ])) - it(`a reload after a truncate keeps the first load waiting for its replay`, () => - runLazyHistory([ + ]) + }) + it(`a reload after a truncate keeps the first load waiting for its replay`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `parent`, key: 3 }, { kind: `settle-last-ok` }, { kind: `truncate` }, { kind: `unparent`, key: 1 }, { kind: `reload` }, - ])) - it(`a truncate replay's success clears the error of a key whose retry it replaced`, () => - runLazyHistory([ + ]) + }) + it(`a truncate replay's success clears the error of a key whose retry it replaced`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, { kind: `unparent`, key: 1 }, { kind: `truncate` }, { kind: `settle-last-ok` }, - ])) - it(`a failed replay succeeds for the demand left after its failed demand retires`, () => - runLazyHistory([ + ]) + }) + it(`a failed replay succeeds for the demand left after its failed demand retires`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }, @@ -6457,9 +6516,11 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 1 }, { kind: `settle-ok` }, { kind: `settle-fail` }, - ])) - it(`a demand change's replacement keeps a reload's replay open in the first load`, () => - runLazyHistory( + ]) + }) + it(`a demand change's replacement keeps a reload's replay open in the first load`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `reload` }, { kind: `truncate` }, @@ -6470,20 +6531,24 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 3 }, ], { holdChildReady: true }, - )) - it(`a first load waits for a source reload's whole replay`, () => - runLazyHistory([ + ) + }) + it(`a first load waits for a source reload's whole replay`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `parent`, key: 3 }, { kind: `parent`, key: 2 }, { kind: `unparent`, key: 1 }, { kind: `reload` }, { kind: `settle-last-fail` }, - ])) + ]) + }) // The CI counterexample of the coarse verdict's predecessor (seed // -838690579): a reload after the source stopped replays the demand, a // demand change joins the replay, and the replay fails with both loads. - it(`a reload after the source stopped replays its demand and fails with every load`, () => - runLazyHistory( + it(`a reload after the source stopped replays its demand and fails with every load`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6494,17 +6559,21 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-fail` }, ], { holdChildReady: true }, - )) - it(`a reload during the first load with a late ready does not fail the query`, () => - runLazyHistory([{ kind: `reload` }, { kind: `settle-ok` }], { + ) + }) + it(`a reload during the first load with a late ready does not fail the query`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([{ kind: `reload` }, { kind: `settle-ok` }], { asyncChildReady: true, - })) + }) + }) // A failed replay stays open for the demand the query still holds (law // 9). The newer errors of key 3's joined load and of the replacement that // throws, a rejection like any other, show over the replay's error; once // their keys retire, the replay's error, still open, shows again. - it(`a failed replay's error shows again when the newer errors over it retire`, () => - runLazyHistory([ + it(`a failed replay's error shows again when the newer errors over it retire`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }, @@ -6516,10 +6585,12 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 2 }, { kind: `parent`, key: 1 }, { kind: `unparent`, key: 3 }, - ])) + ]) + }) // Law 10: a later load decides its key, also after a replay loaded it. - it(`a load that throws after a failed replay shows its own error`, () => - runLazyHistory([ + it(`a load that throws after a failed replay shows its own error`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }, @@ -6528,21 +6599,25 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-ok` }, { kind: `throw-next` }, { kind: `unparent`, key: 1 }, - ])) + ]) + }) // Law 9: once the failed demand retires, the replay succeeds for the // demand it loaded, without another load. - it(`a failed replay succeeds when its failed demand retires after the rest loaded`, () => - runLazyHistory([ + it(`a failed replay succeeds when its failed demand retires after the rest loaded`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }, { kind: `parent`, key: 2 }, { kind: `settle-ok` }, { kind: `unparent`, key: 1 }, - ])) + ]) + }) // Law 10: a replacement that loads the failed key again decides it. - it(`a replacement that loads a failed replay key again lets the replay succeed`, () => - runLazyHistory([ + it(`a replacement that loads a failed replay key again lets the replay succeed`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }, @@ -6550,12 +6625,14 @@ describe(`lazy join demand grammar`, () => { { kind: `parent`, key: 2 }, { kind: `unparent`, key: 3 }, { kind: `settle-ok` }, - ])) + ]) + }) // Law 9: only a demand that made no request waits as parked. A replay // load that a child with a failed first load still serves fails the replay // as any replay load does. - it(`a replay load fails the replay while its child shows a failed first load`, () => - runLazyHistory( + it(`a replay load fails the replay while its child shows a failed first load`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6563,11 +6640,13 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-fail` }, ], { holdChildReady: true }, - )) + ) + }) // Law 9: a replay load that the query aborted settled nothing, so the // parked demand that replaces it is still owed a load. - it(`a parked demand that replaces an aborted replay load holds the replay`, () => - runLazyHistory( + it(`a parked demand that replaces an aborted replay load holds the replay`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, @@ -6577,26 +6656,30 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 1 }, ], { holdChildReady: true }, - )) + ) + }) // Law 9: a truncate aborts the loads before it and replays the demand; an // aborted load that the adapter still finishes decides nothing, so a // request error waits for the replay. - it(`a load the truncate aborted does not clear a request error`, () => - runLazyHistory([ + it(`a load the truncate aborted does not clear a request error`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, { kind: `parent`, key: 3 }, { kind: `truncate` }, { kind: `settle-first-ok` }, - ])) + ]) + }) // Law 10: a demand change retries a segment whose load failed, also while // that failure waits for a replay; the retry then decides the key, and the // first load succeeds. // The truncate after the reload began is the run's own replacement: it // replays again, as every truncate does. - it(`a demand change retries a load that failed during a replay in the first load`, () => - runLazyHistory( + it(`a demand change retries a load that failed during a replay in the first load`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `reload` }, { kind: `truncate` }, @@ -6607,22 +6690,26 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-ok` }, ], { holdChildReady: true }, - )) + ) + }) // Law 10 and law 9: a demand change retries a key whose replay load // failed, and the retry decides it, before the replay is judged. - it(`a demand change retries a key whose truncate replay load failed in the first load`, () => - runLazyHistory([ + it(`a demand change retries a key whose truncate replay load failed in the first load`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `parent`, key: 3 }, { kind: `truncate` }, { kind: `settle-last-fail` }, { kind: `parent`, key: 2 }, { kind: `unparent`, key: 1 }, { kind: `settle-ok` }, - ])) + ]) + }) // A replay re-requests a demand parked behind a failed first load; that // request replaces the parked wait, so the replay completes and publishes. - it(`a truncate replay re-requests parked demand and then publishes it`, () => - runLazyHistory( + it(`a truncate replay re-requests parked demand and then publishes it`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6632,11 +6719,13 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-ok` }, ], { holdChildReady: true }, - )) + ) + }) // Releasing a parked demand removes its parked acquisition, so a demand // for the same key parks again. - it(`a parked demand released and asked for again parks again`, () => - runLazyHistory( + it(`a parked demand released and asked for again parks again`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6646,11 +6735,13 @@ describe(`lazy join demand grammar`, () => { { kind: `parent`, key: 2 }, ], { holdChildReady: true }, - )) + ) + }) // Replays a 30x counterexample: request failures, a thrown request, and a // reload's replay, then a demand change that retires a failed request. - it(`a request error frame after a thrown request and a reload`, () => - runLazyHistory([ + it(`a request error frame after a thrown request and a reload`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, @@ -6666,7 +6757,8 @@ describe(`lazy join demand grammar`, () => { { kind: `throw-next` }, { kind: `reload` }, { kind: `unparent`, key: 3 }, - ])) + ]) + }) // A reload of a failed first load fails its replay, then the child's // restart requests the parked demand; when that demand retires, the // replay's failure still belongs to key 1, which the query holds, so its @@ -6677,8 +6769,9 @@ describe(`lazy join demand grammar`, () => { // and the next demand change parks a retry that holds the failed replay // open (law 10; a 30-times counterexample of the model, which let the // failed request cover the key and judged the replay a success). - it(`a demand change retries a parked key whose replay request failed`, () => - runLazyHistory( + it(`a demand change retries a parked key whose replay request failed`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6692,9 +6785,11 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-ok` }, ], { holdChildReady: true }, - )) - it(`a failed replay's error stays when parked demand retires after the child restarts`, () => - runLazyHistory( + ) + }) + it(`a failed replay's error stays when parked demand retires after the child restarts`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6706,12 +6801,14 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 2 }, ], { holdChildReady: true }, - )) + ) + }) // A replay's failed request keeps its acquisition's coverage, so releasing // a parked key requests nothing more and the replay fails for the key it // could not load again. - it(`a failed replay request still covers its key when a parked key retires`, () => - runLazyHistory( + it(`a failed replay request still covers its key when a parked key retires`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6721,12 +6818,14 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 3 }, ], { holdChildReady: true }, - )) + ) + }) // Law 10: a later demand change drops a parked replacement that no longer // matches the held keys, so a replay failure it would have replaced // stands. - it(`a demand change drops a parked replacement that no longer matches`, () => - runLazyHistory( + it(`a demand change drops a parked replacement that no longer matches`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -6738,7 +6837,8 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 3 }, ], { holdChildReady: true }, - )) + ) + }) // A reload from inside the adapter's loadSubset rejects and changes // nothing; the query keeps its demand and does not crash. // Law 9: a demand change counts as one step. This change retires a @@ -6746,8 +6846,9 @@ describe(`lazy join demand grammar`, () => { // segment's replay load has failed; judged between the two releases, the // replay would succeed, but judged once after the change it fails for the // key the query still holds (law 16). - it(`a demand change that retires a replacement and another segment judges the replay once`, () => - runLazyHistory([ + it(`a demand change that retires a replacement and another segment judges the replay once`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-ok` }, @@ -6758,14 +6859,17 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-last-ok` }, { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, - ])) - it(`a reload from inside loadSubset rejects and leaves the query running`, () => - runLazyHistory([ + ]) + }) + it(`a reload from inside loadSubset rejects and leaves the query running`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload-in-load` }, { kind: `parent`, key: 2 }, { kind: `settle-ok` }, - ])) + ]) + }) // Maintainer decision (law 16): of several open request errors, the node // shows the newest. The load of key 3 fails first and the older load of // key 2 fails after it, so key 2's error shows; once key 2 retires, key @@ -6774,26 +6878,31 @@ describe(`lazy join demand grammar`, () => { // shows, and a replay then fails for keys 2 and 3. The replay's problem // lists both failed loads in failure order after key 2's earlier failure, // which stays open, and shows the newest. - it(`a failed replay's verdict replaces an earlier open failure`, () => - runLazyHistory([ + it(`a failed replay's verdict replaces an earlier open failure`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, { kind: `parent`, key: 3 }, { kind: `reload` }, { kind: `settle-fail` }, - ])) - it(`a failed truncate replay's verdict replaces an earlier open failure`, () => - runLazyHistory([ + ]) + }) + it(`a failed truncate replay's verdict replaces an earlier open failure`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, { kind: `parent`, key: 3 }, { kind: `truncate` }, { kind: `settle-fail` }, - ])) - it(`a replay load replaced by a later load of its key leaves the replay's list`, () => - runLazyHistory([ + ]) + }) + it(`a replay load replaced by a later load of its key leaves the replay's list`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, @@ -6801,22 +6910,26 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, { kind: `settle-last-fail` }, - ])) - it(`the node shows the newest open request error`, () => - runLazyHistory([ + ]) + }) + it(`the node shows the newest open request error`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, { kind: `settle-last-fail` }, { kind: `settle-first-fail` }, { kind: `unparent`, key: 2 }, - ])) + ]) + }) // Maintainer decision (law 16): a replay's success clears the error of // every key it loaded again, also a key whose earlier load threw. The // churn replacement for keys 1 and 2 throws; the truncate replay then // loads both again through the older loads the query still holds. - it(`a replay's success clears every request error of its demand`, () => - runLazyHistory([ + it(`a replay's success clears every request error of its demand`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-ok` }, @@ -6826,13 +6939,15 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 3 }, { kind: `truncate` }, { kind: `settle-ok` }, - ])) + ]) + }) // Maintainer decision: a request error belongs to the request that // failed. The churn replacement for keys 1 and 2 throws while the // truncate replay is in flight; when its demand retires, the replay's // older load of key 1 decides that key, and its success leaves no error. - it(`a replay's success clears the error of a failed request whose demand retired`, () => - runLazyHistory([ + it(`a replay's success clears the error of a failed request whose demand retired`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, @@ -6841,14 +6956,16 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 3 }, { kind: `unparent`, key: 2 }, { kind: `settle-ok` }, - ])) + ]) + }) // Law 9 (maintainer decision): a failed replay stays open while the query // holds demand, and its error clears when its failed demand retires after // the rest loaded. Here the failed demand is the churn replacement for // keys 1 and 3, which fails after the replay failed; when it retires, key // 3's earlier replay load decides key 3, and the replay succeeds. - it(`a failed replay succeeds when a failed replacement retires after the rest loaded`, () => - runLazyHistory([ + it(`a failed replay succeeds when a failed replacement retires after the rest loaded`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }, @@ -6858,13 +6975,15 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 2 }, { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, - ])) + ]) + }) // Law 16 (maintainer decision): only a replay in flight defers a load's // error. After the truncate replay failed, key 3's load and the churn // replacement join it; the replacement's failure shows its own error at // once, although key 3's load is still pending. - it(`a load that joins a failed replay shows its own error when it fails`, () => - runLazyHistory([ + it(`a load that joins a failed replay shows its own error when it fails`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `truncate` }, @@ -6873,25 +6992,29 @@ describe(`lazy join demand grammar`, () => { { kind: `parent`, key: 3 }, { kind: `unparent`, key: 1 }, { kind: `settle-last-fail` }, - ])) + ]) + }) // Law 16 and the maintainer decision on which error shows: keys 2 and 3 // join the reload's replay in flight, and all three loads fail in order. // The replay reports its error, then each joined load fails with its own // error in failure order, so key 3's error, the newest, shows. - it(`a failed replay lists the loads that failed during it in failure order`, () => - runLazyHistory([ + it(`a failed replay lists the loads that failed during it in failure order`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, { kind: `settle-fail` }, - ])) + ]) + }) // Law 10: a key's latest load decides it. After the replay failed, key 2's // load and the churn replacement for keys 2 and 3 join it; key 2's older // load then fails while the replacement is pending, and decides nothing, // so the replay's error still shows. - it(`an older load's failure decides nothing while its key's replacement is pending`, () => - runLazyHistory([ + it(`an older load's failure decides nothing while its key's replacement is pending`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }, @@ -6899,15 +7022,17 @@ describe(`lazy join demand grammar`, () => { { kind: `parent`, key: 3 }, { kind: `unparent`, key: 1 }, { kind: `settle-first-fail` }, - ])) + ]) + }) // A thrown churn replacement fails its keys as a rejected one does // (maintainer decision: a throw is a rejection; law 10: a failed load // covers nothing), so the next demand change retires it, and the older // load that still covers the held key decides it: its failure is the // key's request error now. The twins must agree. for (const replacement of [`throws`, `rejects`] as const) - it(`a churn replacement that ${replacement} leaves its key to the older load after the next demand change`, () => - runLazyHistory([ + it(`a churn replacement that ${replacement} leaves its key to the older load after the next demand change`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, @@ -6922,15 +7047,17 @@ describe(`lazy join demand grammar`, () => { ]), { kind: `unparent`, key: 1 }, { kind: `settle-first-fail` }, - ])) + ]) + }) // Law 9: the replay succeeds only as a replacement for the demand that // remains. A replay load of the churn replacement for {1, 2} loads both // keys, then the replay load of {2} fails, and the query releases the // replacement: what it loaded before its release does not count, so the // replay fails for key 2 (a 30-times counterexample of the model, which // let the released load decide key 2). - it(`a released replay load's success does not count for the replay's verdict`, () => - runLazyHistory([ + it(`a released replay load's success does not count for the replay's verdict`, () => { + oracleRoute(`fixed history`) + return runLazyHistory([ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, @@ -6946,7 +7073,8 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, { kind: `settle-ok` }, - ])) + ]) + }) // Law 10: a failed load covers nothing, so a replacement that asks for its // keys again after it failed is their acquisition now. A failed replay // lists a failed replay load only until such a request, also when the @@ -6981,14 +7109,17 @@ describe(`lazy join demand grammar`, () => { ], ], ] as const) - it(`a failed replay does not list a failed load that ${name} asked for again`, () => - runLazyHistory(steps)) + it(`a failed replay does not list a failed load that ${name} asked for again`, () => { + oracleRoute(`fixed history`) + return runLazyHistory(steps) + }) // Law 9: a failed replay request the query released holds nothing, so a // parked demand change that asks for its key again parks it, and the key // stays covered (a 30-times counterexample of the model, which left it to // the released request's verdict). - it(`a parked growth asks again for a key whose released replay request failed`, () => - runLazyHistory( + it(`a parked growth asks again for a key whose released replay request failed`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `fail` }, @@ -7000,12 +7131,14 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 2 }, ], { holdChildReady: true }, - )) + ) + }) // Q1 (review 2): an ordered window over the join. A lazy load that fails // after readiness is a request error, and the query still publishes the // window's rows (law 16: a failed request does not hold publication). - it(`a failed lazy load under an ordered window still publishes the window`, () => - runLazyHistory( + it(`a failed lazy load under an ordered window still publishes the window`, () => { + oracleRoute(`fixed history`) + return runLazyHistory( [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, @@ -7014,16 +7147,19 @@ describe(`lazy join demand grammar`, () => { { kind: `settle-ok` }, ], { window: 2 }, - )) + ) + }) // The generated replay of the fixed cleanup witness in source-health.test.ts: // a cleanup from the query's subscription's status listener while the // reload replays its demand, for each cleanup form. it.each([false, true])( `a cleanup from a subscription's status listener during a reload leaves no replacement armed (dependents: %s)`, - (dependents) => - runLazyHistory([{ kind: `settle-ok` }], { + (dependents) => { + oracleRoute(`case`) + return runLazyHistory([{ kind: `settle-ok` }], { ending: { at: `subscription-status`, dependents }, - }), + }) + }, ) // A load, then a reload whose load fails. const failedReload: { steps: ReadonlyArray } = { @@ -7221,6 +7357,13 @@ describe(`lazy join demand grammar`, () => { afterAll(() => { if (lazyHistoriesRan) lazyReach.assertReached() }) + const randomCampaign4 = oraclePropertyOptions( + 100, + `source-health.lazy-demand`, + { + examples: lazyExamples, + }, + ) oracleReplayTest(fcTest, `source-health.lazy-demand`).prop( [ fc @@ -7231,9 +7374,7 @@ describe(`lazy join demand grammar`, () => { .map(([prefix, rest]): LazySteps => ({ prefix, rest })), lazyOptions, ], - oraclePropertyOptions(100, `source-health.lazy-demand`, { - examples: lazyExamples, - }), + randomCampaign4, )( `generated lazy demand histories refine the model`, ( @@ -7248,6 +7389,7 @@ describe(`lazy join demand grammar`, () => { ending, }, ) => { + oracleRoute(oracleRouteOf(randomCampaign4)) const { prefix, rest } = lazySteps const steps = [...(prefix?.steps ?? []), ...rest] const holdChildReady = prefix?.holdChildReady ?? generatedHoldChildReady @@ -7313,12 +7455,17 @@ describe(`request error bookkeeping`, () => { fc.record({ kind: fc.constant(`fail-again` as const), key }), fc.record({ kind: fc.constant(`settle` as const), key }), ) + const randomCampaign5 = oraclePropertyOptions( + 100, + `source-health.request-errors`, + ) oracleReplayTest(fcTest, `source-health.request-errors`).prop( [fc.array(step, { maxLength: 12 })], - oraclePropertyOptions(100, `source-health.request-errors`), + randomCampaign5, )( `a node shows the latest failure still failing and lists them all`, (steps) => { + oracleRoute(oracleRouteOf(randomCampaign5)) const tracker = new RequestErrors() const failing: Array<{ key: string; cause: Error; order: number }> = [] let failures = 0 diff --git a/packages/db/tests/source-health.test.ts b/packages/db/tests/source-health.test.ts index 913d1639c8..09133bbf25 100644 --- a/packages/db/tests/source-health.test.ts +++ b/packages/db/tests/source-health.test.ts @@ -26,7 +26,7 @@ import type { import type { PooledView, Row, SyncParams } from './source-health-support.js' // --------------------------------------------------------------------------- -// Request lane +// Request grammar // --------------------------------------------------------------------------- /** @@ -57,7 +57,7 @@ import type { PooledView, Row, SyncParams } from './source-health-support.js' * reload's promise follow the same rules as for an `Error`, and the node's * problem keeps the value as its `cause`. A value that cannot describe * itself (no prototype, a throwing getter) shows the generic message. The - * lane crosses each call site with each kind of value. + * grammar crosses each call site with each kind of value. */ describe(`problem values`, () => { const values: Record unknown> = { @@ -827,7 +827,7 @@ describe(`crash recovery`, () => { // A query whose first load failed waits for `reload()`: another source // becoming ready does not restart it, and its failed load is not retried. The -// main grammar fails only sources, so this lane supplies the query's own +// main grammar fails only sources, so this grammar supplies the query's own // failure. describe(`a query's own failed first load`, () => { it(`does not restart when an unrelated source becomes ready`, async () => { @@ -1538,7 +1538,7 @@ describe(`lazy join demand grammar`, () => { }) // --------------------------------------------------------------------------- -// Effect lane +// Effect grammar // --------------------------------------------------------------------------- /** @@ -1554,7 +1554,7 @@ describe(`lazy join demand grammar`, () => { * problem on the source's node: it does not dispose the Effect, and * `onHealthChange` receives the health tree, with one node per source alias. * - * The lane crosses `skipInitial`, the source mode, and when the source fails. + * The grammar crosses `skipInitial`, the source mode, and when the source fails. * Before initial-query readiness, an eager source has not called `markReady()`, and an * on-demand source still has its first load pending. After it, the * source calls `fail()`, which fails the first load of an on-demand source @@ -1867,7 +1867,7 @@ describe(`effect healthy gate`, () => { }) }) -// The Effect lane above creates the Effect first. A source can already be in +// The Effect grammar above creates the Effect first. A source can already be in // `error` (its first load failed) or `crashed` when the Effect subscribes. // No status change follows, so the Effect must read the status when it // subscribes. Model: the Effect never reaches its gate, so either status ends @@ -1943,7 +1943,7 @@ describe(`effect over a source that already stopped`, () => { * An Effect has no request errors: a load that fails after its gate, as * before it, ends the Effect through `onSourceError`, and no tree it reported * shows the failure. The parents lead the Effect, and a lazy join loads the - * children each new parent row reaches. The lane crosses `skipInitial` with + * children each new parent row reaches. The grammar crosses `skipInitial` with * the parents' source mode. */ describe(`effect load failures`, () => { diff --git a/packages/db/tests/ssr-snapshot-admission-oracle.ts b/packages/db/tests/ssr-snapshot-admission-oracle.ts index 61f562e91e..25650e515f 100644 --- a/packages/db/tests/ssr-snapshot-admission-oracle.ts +++ b/packages/db/tests/ssr-snapshot-admission-oracle.ts @@ -237,7 +237,7 @@ export function expectedSsrRuntimeErrorViews(history: SsrRuntimeErrorHistory) { * hydrated readers and authored browser rows. A mount follows at least one * authoritative reader. Same and different rows, including ready-empty and * loading-empty server views, distinguish row equality from readiness equality. - * Server/browser writes remain in the adjacent handoff grammar: this lane + * Server/browser writes remain in the adjacent handoff grammar: this grammar * isolates a change of receiving reader after a completed handoff. * * Removing initialization kind loses the mount/hydrate distinction. Removing diff --git a/packages/db/tests/ssr-stream-record-wait-oracle.test.ts b/packages/db/tests/ssr-stream-record-wait-oracle.test.ts index 87c307e9d2..46ac7c5bda 100644 --- a/packages/db/tests/ssr-stream-record-wait-oracle.test.ts +++ b/packages/db/tests/ssr-stream-record-wait-oracle.test.ts @@ -19,8 +19,15 @@ import { createDeferred } from '../src/deferred.js' import { getLiveQueryHash } from '../src/live-query-options.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' import { expectedSsrStreamRecordOutcome } from './ssr-stream-view-oracle.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { createOracleReach } from './oracle-reach.js' +import { declareOracle } from './oracle-declaration.js' import { withOracleCleanup } from './utils.js' import type { DehydratedLiveQueryResult } from '../src/client.js' import type { SsrStreamRecordHistory } from './ssr-stream-view-oracle.js' @@ -232,6 +239,11 @@ describe(`SSR stream record lifetime`, () => { `undefined-cause`, `restart-after-cleanup`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(100) }) + : oraclePropertyOptions(100, `ssr.stream-record-lifetime`) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(grammar, async (history) => { reach.beginHistory() @@ -243,9 +255,7 @@ describe(`SSR stream record lifetime`, () => { if (!history.definedCause) reach.reach(`undefined-cause`) if (history.restartAfterCleanup) reach.reach(`restart-after-cleanup`) }), - campaign === `fixed` - ? { numRuns: oracleRuns(100), seed: 2229 } - : oraclePropertyOptions(100, `ssr.stream-record-lifetime`), + parameters, ) reach.assertReached() console.info( @@ -255,3 +265,12 @@ describe(`SSR stream record lifetime`, () => { }) } }) + +// What this oracle owns: the gap report judges the DbClient's SSR query waits +// and stream failure handling in client.ts by this file's tests. The record +// model is checkRecordHistory's expectation over the stream-view oracle. +declareOracle({ + owns: [`src/client.ts`], + check: [checkRecordHistory], + grammar: [grammar], +}) diff --git a/packages/db/tests/sync-metadata-recovery-oracle.ts b/packages/db/tests/sync-metadata-recovery-oracle.ts index 6995c0feb3..0b5747149c 100644 --- a/packages/db/tests/sync-metadata-recovery-oracle.ts +++ b/packages/db/tests/sync-metadata-recovery-oracle.ts @@ -3,7 +3,7 @@ import { declareOracle } from './oracle-declaration.js' import { compareSource, createSessionModel, - persistedPeerOnDemandLane, + persistedPeerOnDemandGrammar, planAction, } from './sync-metadata-session-oracle.js' import type { @@ -78,13 +78,13 @@ export async function runRecoverySchedule( schedule: RecoverySchedule, createDriver: () => Promise, ) { - const lane = persistedPeerOnDemandLane - const model = createSessionModel(lane) + const grammar = persistedPeerOnDemandGrammar + const model = createSessionModel(grammar) const driver = await createDriver() let checkpoints = 0 const checkSource = (cut: string, ended = false) => { const expected = ended - ? createSessionModel({ ...lane, cached: false }).observe() + ? createSessionModel({ ...grammar, cached: false }).observe() : model.observe() const mismatch = compareSource( checkpoints++, diff --git a/packages/db/tests/sync-metadata-session-oracle.property.test.ts b/packages/db/tests/sync-metadata-session-oracle.property.test.ts index 02fb003863..6aa0879588 100644 --- a/packages/db/tests/sync-metadata-session-oracle.property.test.ts +++ b/packages/db/tests/sync-metadata-session-oracle.property.test.ts @@ -4,37 +4,46 @@ import { DbClient, collectionOptions } from '../src/client.js' import { DuplicateKeySyncError } from '../src/errors.js' import { createOracleReach } from './oracle-reach.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { absentMarker, describeMismatch, - flatLane, + flatGrammar, historyRegimes, modelCheckpoints, - nestedConflictLane, - nestedLane, - nestedSeedLane, + nestedConflictGrammar, + nestedGrammar, + nestedSeedGrammar, runHoldInvariance, runKeyIndependence, runSeedOrderInvariance, runSessionHistory, - seedFullLane, - seedLane, + seedFullGrammar, + seedGrammar, sessionCollectionKeys, sessionHistoryArbitrary, } from './sync-metadata-session-oracle.js' +import { declareOracle } from './oracle-declaration.js' import type { ReceiptState, SessionAction, SessionDriver, - SessionLane, + SessionGrammar, SessionModel, } from './sync-metadata-session-oracle.js' import type { SyncConfig } from '../src/types.js' +// What this oracle owns: the gap report judges the DbClient Collection's sync +// path, which applies sources, seeds, holds, and commits, by this file's tests. +// The contract, model, and grammar live in sync-metadata-session-oracle.ts. +declareOracle({ owns: [`src/client.ts`, `src/collection/sync.ts`] }) + /** * Core driver for the sync metadata session oracle. The contract, model, * grammar, and comparison live in `sync-metadata-session-oracle.ts`. @@ -177,7 +186,7 @@ async function createCoreDriver(fullRows = false): Promise { collectionSet: (key, value) => metadata().collection.set(key, value), collectionDelete: (key) => metadata().collection.delete(key), hold: () => { - // Core lanes never generate a store hold. A fresh fixture key each time: no source action or truncate names it. + // Core grammars never generate a store hold. A fresh fixture key each time: no source action or truncate names it. blocker = collection.insert({ id: 1000 + holdCount++, v: 0 }) }, release, @@ -238,11 +247,11 @@ function sortedEntries( } async function assertSessionHistory( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Promise { - const { mismatch } = await runSessionHistory(lane, history, () => - createCoreDriver(lane.fullRows), + const { mismatch } = await runSessionHistory(grammar, history, () => + createCoreDriver(grammar.fullRows), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) } @@ -272,7 +281,7 @@ const witnesses = { // source update that merges over that seed and commits ahead does: the // re-insert then lands on a different row (decision 10). lateSeedUnderMergedUpdate: { - lane: nestedSeedLane, + grammar: nestedSeedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -287,7 +296,7 @@ const witnesses = { // The same under a hold: the inner commit stays accepted but unapplied, so // the seed still marks the row. heldLateSeedUnderMergedUpdate: { - lane: nestedSeedLane, + grammar: nestedSeedGrammar, history: [ a(`hold`), a(`seed`, 1), @@ -302,7 +311,7 @@ const witnesses = { }, // Issue #2220: a held earlier write must stay visible after a later commit. heldCursor: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -315,7 +324,7 @@ const witnesses = { }, // The open transaction overlays every held one, not only the newest. openOverHeld: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -327,7 +336,7 @@ const witnesses = { }, // Independent transactions: an outer abort keeps an inner commit. outerAbortKeepsInnerCommit: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`begin`), a(`begin`), @@ -339,7 +348,7 @@ const witnesses = { // Commit order: the outer transaction commits after the inner one, so its // earlier write wins, and a hold does not change that order. commitOrderDuringHold: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`hold`), a(`begin`), @@ -354,7 +363,7 @@ const witnesses = { // During a hold, the outer transaction can still write and commit after an // inner one commits. outerContinuesAfterInnerDuringHold: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`hold`), a(`begin`), @@ -367,7 +376,7 @@ const witnesses = { }, // An inner truncate applies at its own commit, at once despite a hold. innerTruncateAppliesAtItsCommit: { - lane: nestedLane, + grammar: nestedGrammar, history: [ a(`begin`), a(`upsert`, 1, true), @@ -384,7 +393,7 @@ const witnesses = { // but never makes the source's equal re-insert a duplicate, and the // re-insert still replaces the row: the late seed's extra field goes. seedUnderOpenReinsert: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), { type: `seed`, key: 1, narrow: true, bare: true }, @@ -396,7 +405,7 @@ const witnesses = { }, // A seed never supersedes a source metadata write on a row the source did not write. seedUnderMetadataOnlyWrite: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), a(`rowSet`, 1), @@ -408,7 +417,7 @@ const witnesses = { // insert replaces the seeded row and clears its metadata. Consumers see the // seed until the insert's transaction applies. seedUnderOpenInsert: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), a(`upsert`, 1, false), @@ -419,7 +428,7 @@ const witnesses = { // The same history with the insert committed before the seed arrives: the // outcome does not depend on arrival order. seedAfterCommittedInsert: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`begin`), a(`upsert`, 1, false), @@ -430,7 +439,7 @@ const witnesses = { // A source insert equal to a seeded row is still an insert: the source never // wrote the row, so the insert names its metadata and clears the seed's. equalInsertOverSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -440,7 +449,7 @@ const witnesses = { // The latest seed of a row no source write has written wins whole, so a // narrow seed drops the earlier seed's other field. laterSeedReplacesSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), { type: `seed`, key: 1, narrow: true }, @@ -449,7 +458,7 @@ const witnesses = { // Once the source has updated a seeded row, a later seed is ignored for the // row's data; the update wrote no metadata, so the late seed supplies it. partialUpdateThenLateSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -460,7 +469,7 @@ const witnesses = { }, // The latest seed wins whole: a later seed without metadata leaves none. bareLaterSeedClearsMetadata: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), { type: `seed`, key: 1, bare: true }, @@ -469,7 +478,7 @@ const witnesses = { // A fresh source insert over a row only a seed supplied replaces the row // and, naming no metadata, clears the seed's. insertOverSeedReplaces: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -480,7 +489,7 @@ const witnesses = { // A source update merges over a seeded row under partial row updates, so the // seed's other field and its metadata stay. updateOverSeed: { - lane: seedLane, + grammar: seedGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -491,7 +500,7 @@ const witnesses = { // Under full row updates the update replaces the seeded row; its metadata // stays, because the update names none. updateOverSeedFullRows: { - lane: seedFullLane, + grammar: seedFullGrammar, history: [ a(`seed`, 1), a(`begin`), @@ -501,7 +510,7 @@ const witnesses = { }, // An open truncate hides a held row write: a row read stops at the truncate. openTruncateHidesHeldRow: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -513,7 +522,7 @@ const witnesses = { }, // An open delete hides a held collection entry from `list()`. openDeleteHidesHeldListEntry: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`hold`), a(`begin`), @@ -525,7 +534,7 @@ const witnesses = { }, // `list('gc:')` includes the key equal to the prefix and excludes `gc`. prefixBoundary: { - lane: flatLane, + grammar: flatGrammar, history: [ a(`begin`), a(`collectionSet`, `gc`), @@ -536,7 +545,7 @@ const witnesses = { // of the updated row is an equal re-insert, not a duplicate. A classifier // that merged the held update would keep the inserted row's field `m`. fullUpdateThenEqualInsertDuringHold: { - lane: seedFullLane, + grammar: seedFullGrammar, history: [ a(`begin`), a(`upsert`, 1, true), @@ -549,16 +558,19 @@ const witnesses = { a(`reinsert`, 1, false), ] as Array, }, -} satisfies Record }> +} satisfies Record< + string, + { grammar: SessionGrammar; history: Array } +> /** - * Each lane's replay names: its campaign, and the paired formulations that + * Each grammar's replay names: its campaign, and the paired formulations that * apply to it (key independence without full row updates, seed order with * seeds). */ -const lanes = [ +const grammars = [ { - lane: flatLane, + grammar: flatGrammar, property: `sync-metadata-session.flat`, paired: { 'hold-invariance': `sync-metadata-session.flat.hold-invariance`, @@ -567,7 +579,7 @@ const lanes = [ seed: 2_220_001, }, { - lane: nestedLane, + grammar: nestedGrammar, property: `sync-metadata-session.nested`, paired: { 'hold-invariance': `sync-metadata-session.nested.hold-invariance`, @@ -576,7 +588,7 @@ const lanes = [ seed: 2_220_002, }, { - lane: seedLane, + grammar: seedGrammar, property: `sync-metadata-session.seeds`, paired: { 'hold-invariance': `sync-metadata-session.seeds.hold-invariance`, @@ -585,11 +597,11 @@ const lanes = [ }, seed: 2_220_003, // A seed beneath an open update needs both on one row, and duplicate - // inserts share the lane's weight, so it runs more fixed histories. + // inserts share the grammar's weight, so it runs more fixed histories. runs: 150, }, { - lane: seedFullLane, + grammar: seedFullGrammar, property: `sync-metadata-session.seeds-full`, paired: { 'hold-invariance': `sync-metadata-session.seeds-full.hold-invariance`, @@ -598,7 +610,7 @@ const lanes = [ seed: 2_220_004, }, { - lane: nestedSeedLane, + grammar: nestedSeedGrammar, property: `sync-metadata-session.nested-seeds`, paired: { 'hold-invariance': `sync-metadata-session.nested-seeds.hold-invariance`, @@ -608,7 +620,7 @@ const lanes = [ seed: 2_220_005, }, { - lane: nestedConflictLane, + grammar: nestedConflictGrammar, property: `sync-metadata-session.nested-conflicts`, paired: { 'hold-invariance': `sync-metadata-session.nested-conflicts.hold-invariance`, @@ -616,13 +628,13 @@ const lanes = [ }, seed: 2_220_006, // Invalidation needs an outer insert, an inner write to the same row, - // and the inner commit, so this lane runs more fixed histories. + // and the inner commit, so this grammar runs more fixed histories. runs: 450, }, ] as const /** - * Declared reach (ORC-015): the states each lane's laws need, and how many + * Declared reach (ORC-015): the states each grammar's laws need, and how many * fixed-campaign histories must enter each. A seed over a source insert or an * open update needs both on one row, and a write to an invalid transaction * needs an invalidation first, so those minimums are lower. @@ -672,9 +684,9 @@ const declaredReach: Record> = { ], } -/** One reach counter per minimum, for a lane's fixed campaign. */ -function createLaneReach(lane: SessionLane, property: string) { - const states = declaredReach[lane.name]! +/** One reach counter per minimum, for a grammar's fixed campaign. */ +function createCampaignReach(grammar: SessionGrammar, property: string) { + const states = declaredReach[grammar.name]! const counters = [...new Set(states.map(([, minimum]) => minimum))].map( (minimum) => { const declared = states @@ -688,7 +700,7 @@ function createLaneReach(lane: SessionLane, property: string) { ) return { record(history: ReadonlyArray): void { - const regimes = historyRegimes(lane, history) + const regimes = historyRegimes(grammar, history) for (const { declared, reach } of counters) { reach.beginHistory() for (const regime of regimes) @@ -703,10 +715,11 @@ function createLaneReach(lane: SessionLane, property: string) { describe(`sync metadata session oracle`, () => { it(`derives the named witnesses from the laws`, async () => { + oracleRoute(`fixed history`) type Observation = ReturnType const at = async (name: keyof typeof witnesses, step: number) => { - const { lane, history } = witnesses[name] - return (await modelCheckpoints(lane, history)).get(step) as Observation + const { grammar, history } = witnesses[name] + return (await modelCheckpoints(grammar, history)).get(step) as Observation } // Wrong answer on 30175be48: the held cursor reads as absent. const held = await at(`heldCursor`, 6) @@ -823,8 +836,9 @@ describe(`sync metadata session oracle`, () => { it.each(Object.entries(witnesses))( `keeps the session law on witness %s`, - async (_name, { lane, history }) => { - await assertSessionHistory(lane, history) + async (_name, { grammar, history }) => { + oracleRoute(`fixed history`) + await assertSessionHistory(grammar, history) }, ) }) @@ -840,63 +854,66 @@ describe(`sync metadata session oracle`, () => { */ describe(`sync metadata session grammar ablation`, () => { const ablations: Array<{ - lane: SessionLane + grammar: SessionGrammar drop: (action: SessionAction) => boolean regime: string }> = [ { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `hold`, regime: `read with two held transactions`, }, { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `truncate`, regime: `truncate during a hold`, }, { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `reinsert`, regime: `equal re-insert`, }, { - lane: flatLane, + grammar: flatGrammar, drop: (x) => x.type === `collectionSet` && x.undefinedValue === true, regime: `undefined collection value`, }, { - lane: nestedLane, + grammar: nestedGrammar, drop: (x) => x.type === `cancel`, regime: `abort keeps an inner commit`, }, { - lane: { ...nestedLane, maxDepth: 1 }, + grammar: { ...nestedGrammar, maxDepth: 1 }, drop: () => false, regime: `nested commit`, }, - { lane: seedLane, drop: (x) => x.type === `seed`, regime: `seed` }, + { grammar: seedGrammar, drop: (x) => x.type === `seed`, regime: `seed` }, ] it.each(ablations.map((ablation) => [ablation.regime, ablation] as const))( `removing the axis for %s removes that regime`, - (_regime, { lane, drop, regime }) => { + (_regime, { grammar, drop, regime }) => { + oracleRoute(`fixed seed`) const count = (filter: (action: SessionAction) => boolean) => fc - .sample(sessionHistoryArbitrary(lane), { - numRuns: 200, - seed: 2_220_100, - }) + .sample( + sessionHistoryArbitrary(grammar), + oracleFixedSeed(2_220_100, { numRuns: 200 }), + ) .filter((history) => - historyRegimes(lane, history.filter(filter)).has(regime), + historyRegimes(grammar, history.filter(filter)).has(regime), ).length - const fullLane = - lane.maxDepth === 1 && regime === `nested commit` ? nestedLane : lane + const fullGrammar = + grammar.maxDepth === 1 && regime === `nested commit` + ? nestedGrammar + : grammar const full = fc - .sample(sessionHistoryArbitrary(fullLane), { - numRuns: 200, - seed: 2_220_100, - }) + .sample( + sessionHistoryArbitrary(fullGrammar), + oracleFixedSeed(2_220_100, { numRuns: 200 }), + ) .filter((history) => - historyRegimes(fullLane, history).has(regime), + historyRegimes(fullGrammar, history).has(regime), ).length expect(full).toBeGreaterThan(10) expect(count((action) => !drop(action))).toBe(0) @@ -909,29 +926,36 @@ describe(`sync metadata session grammar ablation`, () => { * It does not consult the model's reads, so it cannot share a fault in them. */ describe(`sync metadata session hold invariance`, () => { - for (const { lane, paired, seed } of lanes) { + for (const { grammar, paired, seed } of grammars) { const invariance = paired[`hold-invariance`] const run = async (history: Array) => { - const mismatch = await runHoldInvariance(lane, history, () => - createCoreDriver(lane.fullRows), + const mismatch = await runHoldInvariance(grammar, history, () => + createCoreDriver(grammar.fullRows), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) } - oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(lane)], { - numRuns: oracleRuns(50), - seed: seed + 100, - })( - `keeps ${lane.name} source reads independent of holds (fixed)`, - run, + oracleReplayTest(fcTest).prop( + [sessionHistoryArbitrary(grammar)], + oracleFixedSeed(seed + 100, { numRuns: oracleRuns(50) }), + )( + `keeps ${grammar.name} source reads independent of holds (fixed)`, + (history) => { + oracleRoute(`fixed seed`) + return run(history) + }, 60_000, ) // A path replay of this property runs here. + const generatedCampaign = oraclePropertyOptions(50, invariance) oracleReplayTest(fcTest, invariance).prop( - [sessionHistoryArbitrary(lane)], - oraclePropertyOptions(50, invariance), + [sessionHistoryArbitrary(grammar)], + generatedCampaign, )( - `keeps ${lane.name} source reads independent of holds (random)`, - run, + `keeps ${grammar.name} source reads independent of holds (random)`, + (history) => { + oracleRoute(oracleRouteOf(generatedCampaign)) + return run(history) + }, 60_000, ) } @@ -946,63 +970,92 @@ const pairedFormulations = [ suffix: `key-independence`, title: `keeps row 1 independent of row 2`, run: runKeyIndependence, - lanes: lanes.filter((entry) => `key-independence` in entry.paired), + grammars: grammars.filter((entry) => `key-independence` in entry.paired), }, { suffix: `seed-order`, title: `keeps outcomes independent of seed arrival`, run: runSeedOrderInvariance, - lanes: lanes.filter((entry) => `seed-order` in entry.paired), + grammars: grammars.filter((entry) => `seed-order` in entry.paired), }, ] as const describe(`sync metadata session paired formulations`, () => { for (const formulation of pairedFormulations) - for (const { lane, paired, seed } of formulation.lanes) { + for (const { grammar, paired, seed } of formulation.grammars) { const name = (paired as Record)[formulation.suffix]! const run = async (history: Array) => { - const mismatch = await formulation.run(lane, history, () => - createCoreDriver(lane.fullRows), + const mismatch = await formulation.run(grammar, history, () => + createCoreDriver(grammar.fullRows), ) if (mismatch) throw new Error(describeMismatch(history, mismatch)) } - oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(lane)], { - numRuns: oracleRuns(50), - seed: seed + 200, - })(`${formulation.title} on ${lane.name} histories (fixed)`, run, 60_000) + oracleReplayTest(fcTest).prop( + [sessionHistoryArbitrary(grammar)], + oracleFixedSeed(seed + 200, { numRuns: oracleRuns(50) }), + )( + `${formulation.title} on ${grammar.name} histories (fixed)`, + (history) => { + oracleRoute(`fixed seed`) + return run(history) + }, + 60_000, + ) // A path replay of this property runs here. + const generatedCampaign2 = oraclePropertyOptions(50, name) oracleReplayTest(fcTest, name).prop( - [sessionHistoryArbitrary(lane)], - oraclePropertyOptions(50, name), - )(`${formulation.title} on ${lane.name} histories (random)`, run, 60_000) + [sessionHistoryArbitrary(grammar)], + generatedCampaign2, + )( + `${formulation.title} on ${grammar.name} histories (random)`, + (history) => { + oracleRoute(oracleRouteOf(generatedCampaign2)) + return run(history) + }, + 60_000, + ) } }) describe(`sync metadata session campaigns`, () => { - for (const entry of lanes) { - const { lane, property, seed } = entry + for (const entry of grammars) { + const { grammar, property, seed } = entry const run = (history: Array) => - assertSessionHistory(lane, history) - const reach = createLaneReach(lane, property) - oracleReplayTest(fcTest).prop([sessionHistoryArbitrary(lane)], { - numRuns: oracleRuns(`runs` in entry ? entry.runs : 100), - seed, - })( - `keeps the session law on ${lane.name} histories (fixed)`, + assertSessionHistory(grammar, history) + const reach = createCampaignReach(grammar, property) + oracleReplayTest(fcTest).prop( + [sessionHistoryArbitrary(grammar)], + oracleFixedSeed(seed, { + numRuns: oracleRuns(`runs` in entry ? entry.runs : 100), + }), + )( + `keeps the session law on ${grammar.name} histories (fixed)`, (history) => { + oracleRoute(`fixed seed`) reach.record(history) return run(history) }, 120_000, ) oracleReplayTest(it)( - `reaches the declared states in the ${lane.name} fixed campaign`, - () => reach.assertReached(), + `reaches the declared states in the ${grammar.name} fixed campaign`, + () => { + oracleRoute(`fixed seed`) + reach.assertReached() + }, ) // A path replay of this property runs here. + const generatedCampaign3 = oraclePropertyOptions(100, property) oracleReplayTest(fcTest, property).prop( - [sessionHistoryArbitrary(lane)], - oraclePropertyOptions(100, property), - )(`keeps the session law on ${lane.name} histories (random)`, run, 60_000) + [sessionHistoryArbitrary(grammar)], + generatedCampaign3, + )( + `keeps the session law on ${grammar.name} histories (random)`, + (history) => { + oracleRoute(oracleRouteOf(generatedCampaign3)) + return run(history) + }, + 60_000, + ) } }) diff --git a/packages/db/tests/sync-metadata-session-oracle.ts b/packages/db/tests/sync-metadata-session-oracle.ts index eacb7199fb..7711beb8d1 100644 --- a/packages/db/tests/sync-metadata-session-oracle.ts +++ b/packages/db/tests/sync-metadata-session-oracle.ts @@ -87,10 +87,10 @@ import { declareOracle } from './oracle-declaration.js' * the error an invalid transaction's commit rejects with. Stale runs and * writes without an open transaction are fixed boundary witnesses, not * generated histories. Only the `seeds-full` and `persisted-peers-full` - * lanes use full row updates. The `persisted-peers` lanes add a second tab's - * source through its notices; no other lane has a peer, and a peer's write + * grammars use full row updates. The `persisted-peers` grammars add a second tab's + * source through its notices; no other grammar has a peer, and a peer's write * reaches this tab only at a point where the wrapper has no durable step or - * storage read in flight. Only the peer lanes model storage, so only they + * storage read in flight. Only the peer grammars model storage, so only they * compare stored rows after settlement. Durable metadata is also compared * after each action whose storage I/O is not held, and after settlement. * A restart reads storage, but this oracle's claim ends with one sync run. A @@ -188,14 +188,14 @@ export type SessionAction = export type PeerNotice = `row` | `metadata` | `delete` | `reload` | `reset` /** - * A lane fixes which actions the grammar may choose and the nesting depth. + * A grammar profile fixes which actions the grammar may choose and the nesting depth. * `flat` histories are the issue's domain: no transaction begins while one * is open. `nested` histories begin transactions inside open ones. `seeds` * add a second writer; `seeds-full` repeats it under full row updates, and - * `nested-seeds` crosses seeds with nested transactions. Each lane is its own - * replay property. + * `nested-seeds` crosses seeds with nested transactions. Each grammar is its own + * oracle property. */ -export type SessionLane = { +export type SessionGrammar = { name: string maxDepth: number seeds: boolean @@ -212,14 +212,14 @@ export type SessionLane = { readHold?: boolean /** * Whether `collectionSet` may store `undefined`. `get` cannot tell such a - * key from an absent one, but `list()` includes it. The persisted lane + * key from an absent one, but `list()` includes it. The persisted grammar * excludes it: stored collection metadata must be bindable. */ undefinedValues?: boolean /** * Whether the Collection uses `rowUpdateMode: 'full'`. An update then * replaces the row instead of merging over it, so a source insert of a - * seeded row drops the seed's other fields. Other lanes use `'partial'`. + * seeded row drops the seed's other fields. Other grammars use `'partial'`. */ fullRows?: boolean /** @@ -262,59 +262,59 @@ export type SessionLane = { peers?: boolean } -export const flatLane: SessionLane = { +export const flatGrammar: SessionGrammar = { name: `flat`, maxDepth: 1, seeds: false, undefinedValues: true, } -export const nestedLane: SessionLane = { +export const nestedGrammar: SessionGrammar = { name: `nested`, maxDepth: 3, seeds: false, undefinedValues: true, } -export const seedLane: SessionLane = { +export const seedGrammar: SessionGrammar = { name: `seeds`, maxDepth: 1, seeds: true, undefinedValues: true, } -export const nestedSeedLane: SessionLane = { +export const nestedSeedGrammar: SessionGrammar = { name: `nested-seeds`, maxDepth: 3, seeds: true, undefinedValues: true, } -export const nestedConflictLane: SessionLane = { +export const nestedConflictGrammar: SessionGrammar = { name: `nested-conflicts`, maxDepth: 3, seeds: false, undefinedValues: true, conflicts: true, } -export const seedFullLane: SessionLane = { +export const seedFullGrammar: SessionGrammar = { name: `seeds-full`, maxDepth: 1, seeds: true, undefinedValues: true, fullRows: true, } -export const persistedFlatLane: SessionLane = { +export const persistedFlatGrammar: SessionGrammar = { name: `persisted-flat`, maxDepth: 1, seeds: false, storeHold: true, insertsReplace: true, } -export const persistedNestedLane: SessionLane = { +export const persistedNestedGrammar: SessionGrammar = { name: `persisted-nested`, maxDepth: 3, seeds: false, storeHold: true, insertsReplace: true, } -export const persistedConflictLane: SessionLane = { +export const persistedConflictGrammar: SessionGrammar = { name: `persisted-conflicts`, maxDepth: 3, seeds: false, @@ -322,7 +322,7 @@ export const persistedConflictLane: SessionLane = { insertsReplace: true, conflicts: true, } -export const persistedCachedLane: SessionLane = { +export const persistedCachedGrammar: SessionGrammar = { name: `persisted-cached`, maxDepth: 2, seeds: false, @@ -330,7 +330,7 @@ export const persistedCachedLane: SessionLane = { insertsReplace: true, cached: true, } -export const persistedSeedLane: SessionLane = { +export const persistedSeedGrammar: SessionGrammar = { name: `persisted-seeds`, maxDepth: 1, seeds: true, @@ -339,7 +339,7 @@ export const persistedSeedLane: SessionLane = { } /** * Seeds crossed with nested transactions through the persisted wrapper, with - * the conflict lane's weighting: an inner re-insert of an enclosing row can + * the conflict grammar's weighting: an inner re-insert of an enclosing row can * commit first and land on a seeded row. The cached row and the favoured * metadata-keeping writes let a row's metadata stay a seed's after the * source wrote the row, so a later seed can replace it. @@ -349,7 +349,7 @@ export const persistedSeedLane: SessionLane = { * persisted wrapper: a read hold keeps an on-demand load open, so the wrapper * buffers transactions committed during it. */ -export const persistedReadLane: SessionLane = { +export const persistedReadGrammar: SessionGrammar = { name: `persisted-reads`, maxDepth: 2, seeds: false, @@ -358,18 +358,18 @@ export const persistedReadLane: SessionLane = { insertsReplace: true, } /** - * The read lane with `DbClient` seeds and the cached row, which a demand for + * The read-hold grammar with `DbClient` seeds and the cached row, which a demand for * every row loads at the start: a transaction the wrapper buffers during a * read may have its signal aborted after it commits, which abandons it. */ -export const persistedReadSeedLane: SessionLane = { - ...persistedReadLane, +export const persistedReadSeedGrammar: SessionGrammar = { + ...persistedReadGrammar, name: `persisted-reads-seeds`, seeds: true, cached: true, onDemand: true, } -export const persistedNestedSeedLane: SessionLane = { +export const persistedNestedSeedGrammar: SessionGrammar = { name: `persisted-nested-seeds`, maxDepth: 2, seeds: true, @@ -383,7 +383,7 @@ export const persistedNestedSeedLane: SessionLane = { * Another tab's source writes beside this tab's source, its seeds, the cached * row, and nested transactions, through the persisted wrapper. */ -export const persistedPeerLane: SessionLane = { +export const persistedPeerGrammar: SessionGrammar = { name: `persisted-peers`, maxDepth: 2, seeds: true, @@ -396,8 +396,8 @@ export const persistedPeerLane: SessionLane = { * The same under full row updates: an update replaces its row, so storage * keeps no field the update dropped, neither the stored row's nor a seed's. */ -export const persistedPeerFullLane: SessionLane = { - ...persistedPeerLane, +export const persistedPeerFullGrammar: SessionGrammar = { + ...persistedPeerGrammar, name: `persisted-peers-full`, fullRows: true, } @@ -405,26 +405,26 @@ export const persistedPeerFullLane: SessionLane = { * The same on demand, with a demand for every row: a reset keeps a held * `DbClient` seed and deletes every other row (decision 19). */ -export const persistedPeerOnDemandLane: SessionLane = { - ...persistedPeerLane, +export const persistedPeerOnDemandGrammar: SessionGrammar = { + ...persistedPeerGrammar, name: `persisted-peers-on-demand`, onDemand: true, } const anyRowKey = fc.constantFrom(...sessionRowKeys) -/** The cached row and metadata a `cached` lane starts with. */ +/** The cached row and metadata a `cached` grammar starts with. */ export const cachedRow = { id: 1, v: `cached`, seeded: `cached` } export const cachedMetadata = { cached: true } -/** A model at the start of a run on this lane. */ -export function createSessionModel(lane: SessionLane): SessionModel { +/** A model at the start of a run on this grammar. */ +export function createSessionModel(grammar: SessionGrammar): SessionModel { const model = new SessionModel( - lane.fullRows, - lane.insertsReplace === true, - lane.onDemand === true, + grammar.fullRows, + grammar.insertsReplace === true, + grammar.onDemand === true, ) - if (lane.cached === true) + if (grammar.cached === true) model.seed(1, cachedRow, { present: true, value: cachedMetadata }, true) return model } @@ -436,13 +436,13 @@ const collectionKeyArbitrary = fc.constantFrom(...sessionCollectionKeys) * transaction, and most histories commit more than once during a hold. */ export function sessionActionArbitrary( - lane: SessionLane, + grammar: SessionGrammar, ): fc.Arbitrary { - // The conflict lane multiplies row writes and names row 1 three times as + // The conflict grammar multiplies row writes and names row 1 three times as // often as row 2. - const rows = lane.conflicts === true ? 3 : 1 + const rows = grammar.conflicts === true ? 3 : 1 const rowKeyArbitrary = - lane.conflicts === true + grammar.conflicts === true ? fc.constantFrom(1, 1, 1, 2) : anyRowKey const weighted = ( @@ -450,7 +450,7 @@ export function sessionActionArbitrary( arbitrary: fc.Arbitrary, ): fc.WeightedArbitrary => ({ weight, arbitrary }) const metadataArbitrary = - lane.keepsMetadata === true + grammar.keepsMetadata === true ? fc.constantFrom(false, false, false, false, false, false, false, true) : fc.boolean() const actions: Array> = [ @@ -458,7 +458,7 @@ export function sessionActionArbitrary( weighted(4, fc.constant({ type: `commit` as const })), weighted(1, fc.constant({ type: `cancel` as const })), weighted( - lane.keepsMetadata === true ? 1 : 2, + grammar.keepsMetadata === true ? 1 : 2, rowKeyArbitrary.map((key) => ({ type: `rowSet` as const, key })), ), weighted( @@ -466,7 +466,7 @@ export function sessionActionArbitrary( rowKeyArbitrary.map((key) => ({ type: `rowDelete` as const, key })), ), weighted( - lane.keepsMetadata === true ? 4 * rows : 2 * rows, + grammar.keepsMetadata === true ? 4 * rows : 2 * rows, fc .tuple(rowKeyArbitrary, metadataArbitrary) .map(([key, metadata]) => ({ type: `upsert` as const, key, metadata })), @@ -480,12 +480,12 @@ export function sessionActionArbitrary( })), ), weighted( - lane.keepsMetadata === true ? 1 : rows, + grammar.keepsMetadata === true ? 1 : rows, rowKeyArbitrary.map((key) => ({ type: `remove` as const, key })), ), weighted(1, fc.constant({ type: `truncate` as const })), - // The conflict lane leaves collection metadata to the other lanes. - ...(lane.conflicts === true + // The conflict grammar leaves collection metadata to the other grammars. + ...(grammar.conflicts === true ? [] : [ weighted( @@ -495,7 +495,7 @@ export function sessionActionArbitrary( .map(([key, roll]) => ({ type: `collectionSet` as const, key, - ...(lane.undefinedValues && roll === 0 + ...(grammar.undefinedValues && roll === 0 ? { undefinedValue: true } : {}), })), @@ -510,26 +510,26 @@ export function sessionActionArbitrary( ]), weighted( 3, - lane.readHold === true + grammar.readHold === true ? fc.constantFrom(`core`, `store`, `read`).map((kind) => ({ type: `hold` as const, store: kind === `store`, read: kind === `read`, })) - : lane.storeHold + : grammar.storeHold ? fc.boolean().map((store) => ({ type: `hold` as const, store })) : fc.constant({ type: `hold` as const }), ), weighted(1, fc.constant({ type: `release` as const })), - ...(lane.readHold === true + ...(grammar.readHold === true ? // Legal only just after a commit during a read, so it weighs more. [weighted(8, fc.constant({ type: `abandon` as const }))] : []), ] - if (lane.seeds || lane.cached === true) + if (grammar.seeds || grammar.cached === true) actions.push( weighted( - lane.keepsMetadata === true ? 1 : 2, + grammar.keepsMetadata === true ? 1 : 2, fc.tuple(rowKeyArbitrary, fc.boolean()).map(([key, metadata]) => ({ type: `insertOverSeed` as const, key, @@ -537,7 +537,7 @@ export function sessionActionArbitrary( })), ), ) - if (lane.seeds) + if (grammar.seeds) actions.push( weighted( 4, @@ -551,7 +551,7 @@ export function sessionActionArbitrary( })), ), ) - if (lane.peers === true) + if (grammar.peers === true) actions.push( weighted( 4, @@ -593,9 +593,9 @@ export function sessionActionArbitrary( } export function sessionHistoryArbitrary( - lane: SessionLane, + grammar: SessionGrammar, ): fc.Arbitrary> { - return fc.array(sessionActionArbitrary(lane), { + return fc.array(sessionActionArbitrary(grammar), { minLength: 12, maxLength: 48, size: `medium`, @@ -611,7 +611,7 @@ export function sessionHistoryArbitrary( * This constructive subgrammar rebuilds the #2281 metadata prefix and varies * both keys, seed shape/metadata and arrival, explicit metadata, repeated * re-inserts, a core hold, the enclosing transaction's ending, and named - * versus full-reload notices. Every action is legal in the peer lanes. Store + * versus full-reload notices. Every action is legal in the peer grammars. Store * and read holds are excluded because their pending work blocks peer notices. * The general session grammar continues to cover their independent laws. */ @@ -726,7 +726,7 @@ export const keptMetadataDurabilityHistoriesArbitrary = fc * seed metadata forms with immediate writes and writes queued behind a store * hold. Releasing that hold may complete both commits before the next action * cut, so the controlled driver records each completed durable transaction. - * All histories replay the same model and comparison, without fixed answers. + * All histories run through the same model and comparison, without fixed answers. */ export const queuedMetadataRepairHistoriesArbitrary = fc .record({ @@ -905,10 +905,10 @@ export function completedSeedReinsertReloadRegime( * replays. The campaign reach test requires each law's premise. */ export function historyRegimes( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Set { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const regimes = new Set() let reading = false // Under full row updates an update can drop a field its row had; the @@ -916,7 +916,7 @@ export function historyRegimes( // counts once an outermost commit accepts it. let pendingDrop = false for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue if (action.type === `hold` && action.read === true) reading = true if (action.type === `release`) reading = false if (reading && writesSource(action) && model.depth > 0) @@ -1085,7 +1085,7 @@ function writesSource(action: SessionAction): boolean { * can land on a different row that the source wrote. That transaction is then * invalid, as a duplicate-key insert would be: no read includes its writes, * it ignores later writes, a truncate included, and its commit rejects. - * Lanes whose inserts replace (the persisted wrapper) keep the transaction, + * Grammars whose inserts replace (the persisted wrapper) keep the transaction, * and the insert replaces that row and clears its metadata. * * The source and consumers read different layers of the same order. The @@ -2128,12 +2128,12 @@ export type SessionDriver = { isDuplicateKeyError?: (error: unknown) => boolean /** * Aborts the signal of the newest outermost transaction committed during - * the read hold. Lanes with read holds use it. + * the read hold. Grammars with read holds use it. */ abandon?: () => void observe: () => SessionObservation /** - * Each row as storage holds it. Where a lane models storage, the settled + * Each row as storage holds it. Where a grammar models storage, the settled * rows must match the model's: a restart reads them, and a full reload * reads them back into this tab. */ @@ -2187,7 +2187,7 @@ export type SessionMismatch = { * compares the full observation at the checkpoint after the action returns. * Illegal actions for the current model state are skipped, so every * generated history is legal: `commit`, `cancel`, and writes need an open - * transaction; `begin` respects the lane's depth; `hold` and `release` + * transaction; `begin` respects the grammar's depth; `hold` and `release` * alternate; `remove` and `reinsert` need the row. A seed is legal at any * point, including over a row the source has inserted. * @@ -2205,7 +2205,7 @@ export type SessionMismatch = { * is an insert. */ export async function runSessionHistory( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise<{ @@ -2213,7 +2213,7 @@ export async function runSessionHistory( steps: number recoveryInputs: ReadonlyArray }> { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const driver = await createDriver() let mismatch: SessionMismatch | undefined let steps = 0 @@ -2235,7 +2235,7 @@ export async function runSessionHistory( applyRecoveryInputs() recordDurableMetadata() for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue steps++ const value = { s: step } let thrown: string | undefined @@ -2272,7 +2272,7 @@ export async function runSessionHistory( mismatch = compareStoredMetadata( step, action, - lane, + grammar, model, driver, durableTrace, @@ -2300,7 +2300,7 @@ export async function runSessionHistory( model.observe(), driver.observe(), ) - if (!mismatch && lane.peers === true && driver.storedRows) { + if (!mismatch && grammar.peers === true && driver.storedRows) { const expected = model.storedRows() const actual = driver.storedRows() if (canonical(expected) !== canonical(actual)) @@ -2316,7 +2316,7 @@ export async function runSessionHistory( mismatch = compareStoredMetadata( history.length, `settle`, - lane, + grammar, model, driver, durableTrace, @@ -2355,12 +2355,12 @@ export async function runSessionHistory( function compareStoredMetadata( step: number, action: SessionAction | `settle`, - lane: SessionLane, + grammar: SessionGrammar, model: SessionModel, driver: SessionDriver, durableTrace: ReadonlyArray>, ): SessionMismatch | undefined { - if (lane.peers !== true || !driver.storedRowMetadata) return undefined + if (grammar.peers !== true || !driver.storedRowMetadata) return undefined // Empty transactions may stutter. Preserve every change of durable metadata, // including a wrong write that a later queued commit repairs during release. const changes = (trace: ReadonlyArray>) => @@ -2394,17 +2394,17 @@ function compareStoredMetadata( * holds does not change which other actions are legal. */ export async function runHoldInvariance( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise { const held = await createDriver() const unheld = await createDriver() - const model = createSessionModel(lane) + const model = createSessionModel(grammar) let mismatch: SessionMismatch | undefined try { for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue const value = { s: step } let thrown: string | undefined try { @@ -2445,13 +2445,13 @@ type PlannedCall = { step: number; action: SessionAction; call: DriverCall } /** The legal actions of a history and their driver calls, planned once. */ function planHistory( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Array { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const planned: Array = [] for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue planned.push({ step, action, call: planAction(model, action, { s: step }) }) } return planned @@ -2504,17 +2504,17 @@ function withoutOtherRow(observation: SessionObservation): SessionObservation { * where the model invalidates a transaction are therefore not compared. */ export async function runKeyIndependence( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise { - if (historyRegimes(lane, history).has(`transaction invalidated`)) + if (historyRegimes(grammar, history).has(`transaction invalidated`)) return undefined const both = await createDriver() const rowOneOnly = await createDriver() let mismatch: SessionMismatch | undefined try { - for (const planned of planHistory(lane, history)) { + for (const planned of planHistory(grammar, history)) { mismatch = await performCall(both, planned) if (mismatch) break if (namesOtherRow(planned.action)) continue @@ -2550,21 +2550,21 @@ export async function runKeyIndependence( * stays equal when the seed moves first. Under partial row updates a seed * that arrives after a source update is ignored, while a seed moved first is * the update's merge base, so consumers' rows are compared only under full - * row updates; metadata is compared in every lane. For the same reason a + * row updates; metadata is compared in every grammar. For the same reason a * re-insert planned from the arrival-order row need not equal the row when - * the seed moves first (decision "b"), so partial lanes drop `reinsert` from + * the seed moves first (decision "b"), so partial grammars drop `reinsert` from * both runs. */ export async function runSeedOrderInvariance( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, createDriver: () => Promise, ): Promise { const asArrived = await createDriver() const seedsFirst = await createDriver() const planned = planHistory( - lane, - lane.fullRows + grammar, + grammar.fullRows ? history : history.filter((action) => action.type !== `reinsert`), ) @@ -2586,7 +2586,7 @@ export async function runSeedOrderInvariance( if (!mismatch) { await Promise.all([asArrived.settle(), seedsFirst.settle()]) const comparable = (observation: SessionObservation) => - lane.fullRows ? observation : { ...observation, base: {} } + grammar.fullRows ? observation : { ...observation, base: {} } mismatch = compare( history.length, `settle`, @@ -2632,13 +2632,13 @@ const modelOnly: SessionDriver = { * expectations derived by hand from the laws. */ export async function modelCheckpoints( - lane: SessionLane, + grammar: SessionGrammar, history: ReadonlyArray, ): Promise> { - const model = createSessionModel(lane) + const model = createSessionModel(grammar) const checkpoints = new Map() for (const [step, action] of history.entries()) { - if (!isLegal(lane, model, action)) continue + if (!isLegal(grammar, model, action)) continue await planAction(model, action, { s: step })(modelOnly) checkpoints.set(step, model.observe()) } @@ -2646,7 +2646,7 @@ export async function modelCheckpoints( } function isLegal( - lane: SessionLane, + grammar: SessionGrammar, model: SessionModel, action: SessionAction, ): boolean { @@ -2654,18 +2654,18 @@ function isLegal( if (model.awaitsAbandonment && action.type !== `release`) return false switch (action.type) { case `begin`: - return model.depth < lane.maxDepth + return model.depth < grammar.maxDepth case `hold`: return !model.isHolding case `release`: return model.isHolding case `abandon`: - return lane.readHold === true && model.canAbandon + return grammar.readHold === true && model.canAbandon case `seed`: - return lane.seeds + return grammar.seeds case `peer`: return ( - lane.peers === true && + grammar.peers === true && !model.blocksPeerNotices && (action.notice === `row` || action.notice === `reload` || @@ -2674,7 +2674,7 @@ function isLegal( ) case `insertOverSeed`: return ( - (lane.seeds || lane.cached === true) && + (grammar.seeds || grammar.cached === true) && model.depth > 0 && model.sourceRow(action.key).present && !model.sourceWroteRow(action.key) diff --git a/packages/db/tests/sync-metadata-session.test.ts b/packages/db/tests/sync-metadata-session.test.ts index d793683ce6..c84ed6feae 100644 --- a/packages/db/tests/sync-metadata-session.test.ts +++ b/packages/db/tests/sync-metadata-session.test.ts @@ -3,7 +3,7 @@ import { DbClient, collectionOptions } from '../src/client.js' import { createCollection } from '../src/collection/index.js' import { createSessionModel, - persistedPeerLane, + persistedPeerGrammar, runSessionHistory, } from './sync-metadata-session-oracle.js' import type { SessionDriver } from './sync-metadata-session-oracle.js' @@ -19,7 +19,7 @@ type SyncActions = Parameters[`sync`]>[0] it(`consumes completed setup inputs before authored actions`, async () => { const order: Array = [] let setup = true - const observation = createSessionModel(persistedPeerLane).observe() + const observation = createSessionModel(persistedPeerGrammar).observe() const driver: SessionDriver = { begin: () => { order.push(`action`) @@ -54,7 +54,7 @@ it(`consumes completed setup inputs before authored actions`, async () => { }, } const result = await runSessionHistory( - persistedPeerLane, + persistedPeerGrammar, [{ type: `begin` }], () => Promise.resolve(driver), ) diff --git a/packages/db/tests/temporal-index-order.test.ts b/packages/db/tests/temporal-index-order.test.ts index 3e041f0022..73a7a67253 100644 --- a/packages/db/tests/temporal-index-order.test.ts +++ b/packages/db/tests/temporal-index-order.test.ts @@ -21,8 +21,8 @@ import type { CompareOptions } from '../src/query/builder/types' * direction, and equal-instant ties, then compares exact ordered keys after * the action. A ready source exercises scan and indexed live-query paths at * preload; an on-demand Collection checks the first published ordered row. - * Index spies prove the named indexed reads. The PlainDate lane checks one - * extended-year calendar boundary; the exception lane requires `TypeError` + * Index spies prove the named indexed reads. The PlainDate grammar checks one + * extended-year calendar boundary; the exception grammar requires `TypeError` * for its named mixed or unordered Temporal pairs and claims no ordered * result for them. Custom index callbacks, mutated built-ins, and other * mixed Temporal pairs remain outside this owner. diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index e7b5f356d2..7c2094d6e8 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -3,7 +3,13 @@ import * as fc from 'fast-check' import { createCollection } from '../src/collection/index.js' import { createTransaction } from '../src/transactions.js' import { DuplicateTransactionIdError } from '../src/errors.js' -import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from './oracle-config.js' import { declareOracle } from './oracle-declaration.js' import { captureCreatedTransactions, @@ -742,24 +748,35 @@ async function runHistory(steps: ReadonlyArray): Promise { const history = fc.array(step, { minLength: 1, maxLength: 24 }) -declareOracle({ check: [runHistory], model: [OwnershipModel] }) +// What this oracle owns: the gap report judges the transaction's settlement +// and the Collection's tracking of it, by this file's tests. +declareOracle({ + owns: [`src/transactions.ts`, `src/collection/state.ts`], + check: [runHistory], + model: [OwnershipModel], +}) describe(`settled transactions leave every Collection that tracked them`, () => { it(`generated histories with a fixed seed`, async () => { + const parameters = oracleFixedSeed(2080, { numRuns: oracleRuns(200) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(history, (steps) => runHistory(steps)), - { numRuns: oracleRuns(200), seed: 2080 }, + parameters, ) }, 300_000) it(`generated histories with a random or replayed seed`, async () => { + const campaignParameters = oraclePropertyOptions(200, PROPERTY) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(history, (steps) => runHistory(steps)), - oraclePropertyOptions(200, PROPERTY), + campaignParameters, ) }, 300_000) - it(`pinned: a subscriber throws during an asynchronous success and failure settlement`, async () => { + it(`fixed history: a subscriber throws during an asynchronous success and failure settlement`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 3 }, @@ -774,7 +791,8 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a merged-away pair, a conflicting rollback that throws, and a truncate rollback`, async () => { + it(`fixed history: a merged-away pair, a conflicting rollback that throws, and a truncate rollback`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `open`, reuse: undefined }, { type: `cancel`, tx: 0, on: `A` }, @@ -792,7 +810,8 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a rolled-back transaction does not remove a live one that shares its id`, async () => { + it(`fixed history: a rolled-back transaction does not remove a live one that shares its id`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, @@ -807,7 +826,8 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a second live transaction with a tracked id is rejected`, async () => { + it(`fixed history: a second live transaction with a tracked id is rejected`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, @@ -816,7 +836,8 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: conflicting rollbacks that throw settle every transaction and rethrow one subscriber error`, async () => { + it(`fixed history: conflicting rollbacks that throw settle every transaction and rethrow one subscriber error`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, @@ -830,7 +851,8 @@ describe(`settled transactions leave every Collection that tracked them`, () => ]) }) - it(`pinned: a failed commit whose rollback subscriber throws keeps the mutation error`, async () => { + it(`fixed history: a failed commit whose rollback subscriber throws keeps the mutation error`, async () => { + oracleRoute(`fixed history`) await runHistory([ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, diff --git a/packages/db/tests/truncate-readiness-oracle.test.ts b/packages/db/tests/truncate-readiness-oracle.test.ts index 195d31bc00..a1dcc89137 100644 --- a/packages/db/tests/truncate-readiness-oracle.test.ts +++ b/packages/db/tests/truncate-readiness-oracle.test.ts @@ -19,6 +19,7 @@ import { expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { declareOracle } from './oracle-declaration.js' +import { oracleRoute } from './oracle-config.js' import type { SyncConfig } from '../src/types.js' type Row = { id: number; value: string } @@ -67,7 +68,11 @@ function expectReentrantStatuses( expect(statuses).toEqual(last === false ? [] : ['ready']) } +// What this oracle owns: the gap report judges how a truncate's replacement +// rows set the Collection's readiness, in the sync path of sync.ts, by this +// file's tests. declareOracle({ + owns: [`src/collection/sync.ts`], check: [expectReplacementStatus, expectReentrantStatuses], model: [expectedReplacementStatus], }) @@ -75,6 +80,7 @@ declareOracle({ it.each(cells)( 'replacement readiness follows the final authored intent: %j', async ({ status, held, first, last }) => { + oracleRoute(`case`) let sync!: Sync const finish = createDeferred() const c = createCollection({ @@ -146,6 +152,7 @@ it.each(cells)( it.each(intents.flatMap((first) => intents.map((last) => ({ first, last }))))( 'composes readiness of reentrant replacement batches: %j', async ({ first, last }) => { + oracleRoute(`case`) let sync!: Sync const c = createCollection({ getKey: (row) => row.id, diff --git a/packages/db/tests/utils-oracle.property.test.ts b/packages/db/tests/utils-oracle.property.test.ts index bba9dab041..129a99d587 100644 --- a/packages/db/tests/utils-oracle.property.test.ts +++ b/packages/db/tests/utils-oracle.property.test.ts @@ -4,8 +4,11 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { Temporal } from 'temporal-polyfill' import { deepEquals, sameRowValue } from '../src/utils' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from './oracle-config.js' import { @@ -24,6 +27,10 @@ import { sparseArrayCase, } from './sparse-array-value-oracle.js' +// What this oracle owns: the gap report judges the deepEquals relation in +// utils.ts by this file's tests. +declareOracle({ owns: [`src/utils.ts`] }) + /** * The `deepEquals` API contract in `../src/utils.ts`, reinforced by the * established examples in `utils.test.ts`, promises generic value equality. @@ -73,14 +80,24 @@ function utilsProperty]>(arbitraries: { // The mapped arbitraries preserve each position's Ts member. const argumentsArbitrary = fc.tuple(...arbitraries) as fc.Arbitrary - oracleReplayTest(fcTest).prop([argumentsArbitrary], { - seed: fixedSeed, + const argumentsFixedSeed = oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(runs), - })(`${label} fixed`, (values) => check(...values)) + }) + oracleReplayTest(fcTest).prop([argumentsArbitrary], argumentsFixedSeed)( + `${label} fixed`, + (values) => { + oracleRoute(oracleRouteOf(argumentsFixedSeed)) + check(...values) + }, + ) + const argumentsCampaign = oraclePropertyOptions(runs, property) oracleReplayTest(fcTest, property).prop( [argumentsArbitrary], - oraclePropertyOptions(runs, property), - )(`${label} random`, (values) => check(...values)) + argumentsCampaign, + )(`${label} random`, (values) => { + oracleRoute(oracleRouteOf(argumentsCampaign)) + check(...values) + }) } } @@ -265,7 +282,7 @@ function adjacentDateTime(time: number): number { // Legal rings have 1–5 reachable nodes, integer payloads, and one of four // native edge carriers at each node. A zero-node ring has no root and is // excluded. The fixed pair enumeration below reconstructs every ordered -// two-carrier overlap; the length cases pin the lower and upper bounds. +// two-carrier overlap; the length cases fix the lower and upper bounds. const ringArbitrary = fc.array( fc.record({ value: fc.integer(), @@ -281,9 +298,11 @@ describe(`deepEquals property-based tests`, () => { (cells) => expectRingLaw(cells), ) it(`runs a fixed mixed-ring campaign without skipped antecedents`, () => { + const parameters = oracleFixedSeed(101301, { numRuns: 100 }) + oracleRoute(oracleRouteOf(parameters)) const result = fc.check( fc.property(ringArbitrary, (cells) => expectRingLaw(cells)), - { seed: 101301, numRuns: 100 }, + parameters, ) expect(result).toMatchObject({ failed: false, numRuns: 100, numSkips: 0 }) }) @@ -347,6 +366,7 @@ describe(`deepEquals property-based tests`, () => { }, ) it(`shrinks and replays a payload-blind graph comparator`, () => { + oracleRoute(`calibration`) const property = fc.property(ringArbitrary, (cells) => expectRingLaw(cells, () => true), ) @@ -497,11 +517,13 @@ describe(`deepEquals property-based tests`, () => { }, ) it(`fixed campaign reaches an extra property in every case`, () => { + const campaignParameters = oracleFixedSeed(20260911, { numRuns: 100 }) + oracleRoute(oracleRouteOf(campaignParameters)) const result = fc.check( fc.property(arbitraryExtraProperty, (sample) => { assertExtraProperty(sample, deepEquals) }), - { seed: 20260911, numRuns: 100 }, + campaignParameters, ) expect(result).toMatchObject({ failed: false, numRuns: 100, numSkips: 0 }) }) @@ -554,7 +576,7 @@ describe(`deepEquals property-based tests`, () => { }) // `deepEquals` drives change-event suppression, so it deliberately ignores // state that draft revert detection keeps (see proxy-revert-oracle): Map and - // Set insertion order, RegExp `lastIndex`, and array holes. These laws pin + // Set insertion order, RegExp `lastIndex`, and array holes. These laws fix // that current relation so a shared walker cannot leak draft rules into it. describe(`order and state the general relation ignores`, () => { utilsProperty([ diff --git a/packages/db/tests/utils.test.ts b/packages/db/tests/utils.test.ts index 8570b58130..75402c22fd 100644 --- a/packages/db/tests/utils.test.ts +++ b/packages/db/tests/utils.test.ts @@ -58,7 +58,7 @@ describe(`oracle run configuration`, () => { }), ).toEqual({ multiplier: 100, - replaySeed: -42, + campaignSeed: -42, replayPath: `1:0:2`, replayProperty: `includes.incremental-history`, }) @@ -67,7 +67,7 @@ describe(`oracle run configuration`, () => { it(`uses one run multiplier and no replay coordinates by default`, () => { expect(readOracleRunConfig({})).toEqual({ multiplier: 1, - replaySeed: undefined, + campaignSeed: undefined, replayPath: undefined, replayProperty: undefined, }) @@ -137,12 +137,12 @@ describe(`oracle run configuration`, () => { it(`adds a shrink path only to its named property`, () => { const ordinaryRun = { - replaySeed: undefined, + campaignSeed: undefined, replayPath: undefined, replayProperty: undefined, } const replayRun = { - replaySeed: -42, + campaignSeed: -42, replayPath: `1:0:2`, replayProperty: `includes.incremental-history`, } diff --git a/packages/db/tests/virtual-props-cache.test.ts b/packages/db/tests/virtual-props-cache.test.ts index 7bca5712bf..f83be003bb 100644 --- a/packages/db/tests/virtual-props-cache.test.ts +++ b/packages/db/tests/virtual-props-cache.test.ts @@ -5,7 +5,7 @@ import { mockSyncCollectionOptions, withExpectedRejection } from './utils.js' /** * A row read with virtual props is a copy, cached so repeated reads and - * publications share one object. These laws pin what that cache must keep: + * publications share one object. These laws fix what that cache must keep: * the value a change publishes is the row later reads return, for every * subscriber, and a key that leaves the collection leaves the cache. A sync * source that reuses a row object and changes it in place still reads its new diff --git a/packages/db/tsconfig.aggregate.json b/packages/db/tsconfig.aggregate.json index 90cba389e5..e5865425eb 100644 --- a/packages/db/tsconfig.aggregate.json +++ b/packages/db/tsconfig.aggregate.json @@ -11,9 +11,6 @@ }, "include": [ "tests/query/aggregate-select-oracle.property.test.ts", - "tests/declared-reach-oracle.property.test.ts", - "tests/oracle-campaign.test.ts", - "tests/oracle-reach.test.ts", "tests/oracle-replay.test.ts", "tests/query/includes-publication-oracle.test.ts", "../db-ivm/tests/structural-identity-oracle.property.test.ts", diff --git a/packages/db/vitest.aggregate.config.mjs b/packages/db/vitest.aggregate.config.mjs index 4ae10b1692..df40db1eb3 100644 --- a/packages/db/vitest.aggregate.config.mjs +++ b/packages/db/vitest.aggregate.config.mjs @@ -16,9 +16,6 @@ export default { setupFiles: [path(`./tests/test-setup.ts`)], include: [ `tests/query/aggregate-select-oracle.property.test.ts`, - `tests/declared-reach-oracle.property.test.ts`, - `tests/oracle-campaign.test.ts`, - `tests/oracle-reach.test.ts`, `tests/oracle-replay.test.ts`, ], maxWorkers: 1, diff --git a/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts b/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts index fc2d9bfb09..c64a4d6d36 100644 --- a/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts +++ b/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts @@ -4,6 +4,11 @@ import { IR } from '@tanstack/db' import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest' import { makePgClient } from '../../db-collection-e2e/support/global-setup' import { compileSQL } from '../src/sql-compiler' +import { + oracleFixedSeed, + oracleRoute, + oracleRouteOf, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import type { Client } from 'pg' @@ -256,7 +261,10 @@ function evaluate(value: Predicate, row: Row): Truth { return actual <= value.value } -declareOracle({ model: [evaluate] }) +declareOracle({ + model: [evaluate], + owns: [`src/sql-compiler.ts#compileSQL`], +}) describe(`Electric predicate compiler semantics`, () => { let client: Client @@ -291,6 +299,43 @@ describe(`Electric predicate compiler semantics`, () => { const version = await client.query<{ server_version: string }>( `SHOW server_version`, ) + const parameters = oracleFixedSeed(1814, { + numRuns: 50, + examples: [ + ...([`eq`, `in`, `isNull`] as const).map( + (op): [Array, Predicate] => [ + [ + { id: 1, count: 4, label: null, enabled: true }, + { id: 2, count: 2, label: `a`, enabled: false }, + { id: 3, count: null, label: `O'Reilly`, enabled: null }, + ], + { + type: `test`, + op, + expected: true, + both: true, + value: + op === `in` + ? { + type: `not`, + value: { + type: `compare`, + op: `eq`, + column: `enabled`, + value: true, + }, + } + : { type: `compare`, op: `gt`, column: `count`, value: 3 }, + }, + ], + ), + [ + [{ id: 1, count: 0, label: `O'Reilly`, enabled: null }], + { type: `compare`, op: `eq`, column: `label`, value: `O'Reilly` }, + ], + ], + }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( rows, @@ -323,45 +368,7 @@ describe(`Electric predicate compiler semantics`, () => { executedCells++ }, ), - { - seed: 1814, - numRuns: 50, - // Compound booleans are values too: cross comparison, membership, and - // null tests, including NOT with both possible membership values. - examples: [ - ...([`eq`, `in`, `isNull`] as const).map( - (op): [Array, Predicate] => [ - [ - { id: 1, count: 4, label: null, enabled: true }, - { id: 2, count: 2, label: `a`, enabled: false }, - { id: 3, count: null, label: `O'Reilly`, enabled: null }, - ], - { - type: `test`, - op, - expected: true, - both: true, - value: - op === `in` - ? { - type: `not`, - value: { - type: `compare`, - op: `eq`, - column: `enabled`, - value: true, - }, - } - : { type: `compare`, op: `gt`, column: `count`, value: 3 }, - }, - ], - ), - [ - [{ id: 1, count: 0, label: `O'Reilly`, enabled: null }], - { type: `compare`, op: `eq`, column: `label`, value: `O'Reilly` }, - ], - ], - }, + parameters, ) expect(executedCells).toBe(50) expect([...reached].sort()).toEqual( @@ -373,6 +380,7 @@ describe(`Electric predicate compiler semantics`, () => { }) it(`rejects representative compiler faults against PostgreSQL`, async () => { + oracleRoute(`calibration`) const world: Array = [ { id: 1, count: 2, label: null, enabled: true }, { id: 2, count: 2, label: null, enabled: false }, diff --git a/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md b/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md index b26531de3f..15c20edb89 100644 --- a/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md +++ b/packages/electric-db-collection/tests/ORACLE_MUTATIONS.md @@ -137,9 +137,9 @@ accepted data-before-reset and reset-before-data in the protocol model. `electric-sdk-framing.test.ts` independently uses the real SDK with controlled HTTP responses. Both normal and stale-row-bearing 409 bodies produce singleton -reset callbacks, as the generated protocol histories now require. This pins the -installed SDK's HTTP reset path, not an exhaustive specification of every possible -server response or SSE path. +reset callbacks, as the generated protocol histories now require. This fixes the +installed SDK's HTTP reset path, not an exhaustive specification of every +possible server response or SSE path. The synthetic partition property remains as extra adapter robustness coverage; it deliberately tests more callback shapes than the SDK-framed differential @@ -159,11 +159,11 @@ delivers a new insert and a partial update after the initial up-to-date. Every contiguous callback partition is tested. Expected rows follow tag membership and ordinary row updates, without consulting adapter transaction state. -The bug was RED in two progressive fixed cases and the random property (seed -`-1632249566`, path `0:1`); seven fixed controls passed. Previous tag tests began -after readiness, while the initial-sync generators lacked tag move-outs followed -by later live work. This was also reproduced by the full service-backed Electric -E2E suite, not only a synthetic callback driver. +The bug was RED in two progressive fixed histories and the random property (seed +`-1632249566`, path `0:1`); seven fixed controls passed. Previous tag tests +began after readiness, while the initial-sync generators lacked tag move-outs +followed by later live work. This was also reproduced by the full service-backed +Electric E2E suite, not only a synthetic callback driver. Use this focused form while iterating: @@ -209,9 +209,10 @@ after user callbacks fence both the waiter loop and the remaining message batch. All three extensions have fixed-seed and random properties, with the shared oracle multiplier and seed/path replay controls. These are ordinary assertions, -not expected-failure classifiers. At `049cc9a5d`, fixed seeds `42711`, `42712`, -and `42713` reproduce the three failures respectively. Binding and reentry use -epoch/ownership guards; persisted membership uses the approved refetch contract. +not expected-failure classifiers. At `049cc9a5d`, fixed campaign seeds `42711`, +`42712`, and `42713` reproduce the three failures respectively. Binding and +reentry use epoch/ownership guards; persisted membership uses the approved +refetch contract. The separate mixed `[insert, must-refetch, up-to-date]` report exposed the protocol-model gap corrected in section 12. It remains outside the verified diff --git a/packages/electric-db-collection/tests/async-hold-outcomes.test.ts b/packages/electric-db-collection/tests/async-hold-outcomes.test.ts index 0cb9823ed5..9f2a88b3ab 100644 --- a/packages/electric-db-collection/tests/async-hold-outcomes.test.ts +++ b/packages/electric-db-collection/tests/async-hold-outcomes.test.ts @@ -14,7 +14,7 @@ import type { Message } from '@electric-sql/client' * and during the fetch. The retry-decision law in * `electric-oracle.property.test.ts` owns the outcomes of a stream error, an * `onError` decision, an Electric client give-up, an external abort, and - * cleanup. This file pins two outcomes outside that grammar: + * cleanup. This file fixes two outcomes outside that grammar: * * - a must-refetch after the first full sync renews the wait, so a new load * settles only with the replacement snapshot; diff --git a/packages/electric-db-collection/tests/documented-mutations-oracle.test.ts b/packages/electric-db-collection/tests/documented-mutations-oracle.test.ts index 4889a5fe6d..cf0fd9d342 100644 --- a/packages/electric-db-collection/tests/documented-mutations-oracle.test.ts +++ b/packages/electric-db-collection/tests/documented-mutations-oracle.test.ts @@ -9,7 +9,7 @@ import { createCollection } from '@tanstack/db' import { electricCollectionOptions } from '../src/electric' import { createDeferred } from '../../db/src/deferred' import { declareOracle } from '../../db/tests/oracle-declaration' -import { oracleReplayTest } from '../../db/tests/oracle-config' +import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' import { checkDocumentedMutation, documentedMutationHistories, @@ -44,6 +44,7 @@ afterAll(() => reach.finish()) oracleReplayTest(test).each(histories)( `runs the actual documented handler: $id`, async (history) => { + oracleRoute(`case`) reach.begin(history.id) await runDocumentedMutation( history, @@ -131,4 +132,5 @@ oracleReplayTest(test).each(histories)( declareOracle({ check: [checkDocumentedMutation], grammar: [documentedMutationHistories], + owns: [`src/electric.ts#electricCollectionOptions`], }) diff --git a/packages/electric-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts b/packages/electric-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts index 74e78aa5dd..0ff03823e4 100644 --- a/packages/electric-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { describe, it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -52,6 +55,7 @@ async function campaign( const reach = createOracleReach(name, reachStatesFor(checked), { minimum: 5, }) + oracleRoute(oracleRouteOf(options)) await fc.assert(eagerLiveSourceProperty(checked, reach), options) return reach } @@ -60,7 +64,9 @@ describe(`eager live-source conformance: Electric`, () => { oracleReplayTest(it)( `conforms on a fixed-seed campaign`, async () => { - const reach = await campaign({ seed: 2118, numRuns: oracleRuns(runs) }) + const reach = await campaign( + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), + ) reach.assertReached() }, campaignTimeoutMs, @@ -81,7 +87,7 @@ describe(`eager live-source conformance: Electric`, () => { `conforms without an app onError on a fixed-seed campaign`, async () => { const reach = await campaign( - { seed: 2118, numRuns: oracleRuns(runs) }, + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), electricConformanceDriver({ appOnError: false }), `${property}.no-on-error`, ) @@ -92,6 +98,7 @@ describe(`eager live-source conformance: Electric`, () => { }) declareOracle({ + owns: [`src/electric.ts#electricCollectionOptions`], check: [checkEagerLiveSourceHistory, eagerLiveSourceProperty], model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], grammar: [historyArb], 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 1b68932edf..44f9cc4362 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 @@ -4,8 +4,11 @@ import { createCollection } from '@tanstack/db' import { ShapeStream } from '@electric-sql/client' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -414,6 +417,7 @@ fixedCase.each([ ])( `settles restart demand with tagged=$tagged legacy=$legacyResume cold=$cold reset=$fresh`, async ({ cold, fresh, ...history }) => { + oracleRoute(`case`) await runTagHistory( { ...history, @@ -432,19 +436,27 @@ 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 -oracleReplayTest(fcTest).prop([tagHistory], { - seed: 42712, - numRuns: oracleRuns(10), -})( +const tagHistoryFixedSeed = oracleFixedSeed(42712, { numRuns: oracleRuns(10) }) +oracleReplayTest(fcTest).prop([tagHistory], tagHistoryFixedSeed)( `persisted tag histories preserve membership across warm and cold restart (fixed)`, - (history) => runTagHistory(history), + (history) => { + oracleRoute(oracleRouteOf(tagHistoryFixedSeed)) + return runTagHistory(history) + }, +) +const tagHistoryCampaign = oraclePropertyOptions( + 10, + `electric.persisted-tag-history`, ) oracleReplayTest(fcTest, `electric.persisted-tag-history`).prop( [tagHistory], - oraclePropertyOptions(10, `electric.persisted-tag-history`), + tagHistoryCampaign, )( `persisted tag histories preserve membership across warm and cold restart (random)`, - (history) => runTagHistory(history), + (history) => { + oracleRoute(oracleRouteOf(tagHistoryCampaign)) + return runTagHistory(history) + }, ) beforeEach(() => { @@ -514,6 +526,7 @@ beforeEach(() => { fixedCase.each([`eager`, `on-demand`, `progressive`] as const)( `delivers abandoned tag-recovery callbacks after replacement starts in %s mode`, async (syncMode) => { + oracleRoute(`case`) await runTagHistory({ syncMode, tagged: true, @@ -607,19 +620,29 @@ async function runOwnerHistory(history: { } } -oracleReplayTest(fcTest).prop([ownerHistory], { - seed: 42711, +const ownerHistoryFixedSeed = oracleFixedSeed(42711, { numRuns: oracleRuns(12), -})( +}) +oracleReplayTest(fcTest).prop([ownerHistory], ownerHistoryFixedSeed)( `config derivation preserves independent owner histories (fixed)`, - runOwnerHistory, + (history) => { + oracleRoute(oracleRouteOf(ownerHistoryFixedSeed)) + return runOwnerHistory(history) + }, +) +const ownerHistoryCampaign = oraclePropertyOptions( + 20, + `electric.bound-descriptor-history`, ) oracleReplayTest(fcTest, `electric.bound-descriptor-history`).prop( [ownerHistory], - oraclePropertyOptions(20, `electric.bound-descriptor-history`), + ownerHistoryCampaign, )( `config derivation preserves independent owner histories (random)`, - runOwnerHistory, + (history) => { + oracleRoute(oracleRouteOf(ownerHistoryCampaign)) + return runOwnerHistory(history) + }, ) // The Electric descriptor may be reused across independent persisted wrappers. @@ -629,4 +652,7 @@ oracleReplayTest(fcTest, `electric.bound-descriptor-history`).prop( // public checkpoints are admission, the first receipt, and each Collection's // rows after only the first provider session acknowledges its transaction. -declareOracle({ check: [runTagHistory, runOwnerHistory] }) +declareOracle({ + check: [runTagHistory, runOwnerHistory], + owns: [`src/electric.ts#electricCollectionOptions`], +}) diff --git a/packages/electric-db-collection/tests/electric-live-query.test.ts b/packages/electric-db-collection/tests/electric-live-query.test.ts index 9616251d8c..ab57bd3cb8 100644 --- a/packages/electric-db-collection/tests/electric-live-query.test.ts +++ b/packages/electric-db-collection/tests/electric-live-query.test.ts @@ -901,7 +901,7 @@ describe(`Electric Collection with Live Query - syncMode integration`, () => { // Initial-query readiness and Effect `skipInitial` follow the settled subset // in both cases. Electric buffers progressive changes until the full sync, // so it cannot deliver a change between settlement and source readiness; - // the controlled core lane owns that history. + // the controlled core path owns that history. describe(`initial readiness follows the settled subset`, () => { const selectedUser = sampleUsers[0]! const laterUser = { ...sampleUsers[3]!, active: true, id: 5 } @@ -964,7 +964,7 @@ describe(`Electric Collection with Live Query - syncMode integration`, () => { ]) }) - // A known limit, pinned so that a change to Electric's buffering is + // A known limit, fixed so that a change to Electric's buffering is // visible: before the full sync, a ready progressive query shows its // snapshot rows, and stream changes to them wait for the atomic swap. it(`a ready query's rows update only when the full sync completes`, async () => { 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 e66c983cdf..501bd7405b 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -16,8 +16,11 @@ import { import { queryCollectionOptions } from '../../query-db-collection/src/query' import { electricCollectionOptions } from '../src/electric' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -106,15 +109,29 @@ function electricHistoryProperty( registeredHistoryProperties.add(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], { + oracleReplayTest(fcTest).prop( + [arbitrary], + oracleFixedSeed(fixedSeed, { numRuns: oracleRuns(baseRuns), examples }), + )( + `${name} (fixed)`, + (...args) => { + oracleRoute(`fixed seed`) + return check(...args) + }, + timeout, + ) + const randomCampaign = { ...oraclePropertyOptions(baseRuns, property), examples, - })(`${name} (random)`, check, timeout) + } + oracleReplayTest(fcTest, property).prop([arbitrary], randomCampaign)( + `${name} (random)`, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign)) + return check(...args) + }, + timeout, + ) } vi.mock(`@electric-sql/client`, async () => { @@ -155,6 +172,7 @@ describe(`mutation outcome and confirmation`, () => { it.each(mutationHistories)( `settles a handler independently of discarded values: %j`, async (history) => { + oracleRoute(`case`) mutationReach.beginHistory() const { operation, confirmation, outcome, returned } = history const localReach = createOracleReach(`electric.mutation-cuts`, [ @@ -418,7 +436,8 @@ function durablePrefixPropertyOptions(mode: `fixed` | `random`): { seed?: number path?: string } { - if (mode === `fixed`) return { numRuns: oracleRuns(10), seed: 20260921 } + if (mode === `fixed`) + return oracleFixedSeed(20260921, { numRuns: oracleRuns(10) }) return oraclePropertyOptions(10, durablePrefixProperty) } @@ -430,7 +449,8 @@ function persistencePolicyPropertyOptions( seed?: number path?: string } { - if (mode === `fixed`) return { numRuns: oracleRuns(8), seed: 20260923 } + if (mode === `fixed`) + return oracleFixedSeed(20260923, { numRuns: oracleRuns(8) }) return oraclePropertyOptions(8, property) } @@ -640,10 +660,9 @@ function queuedPresencePropertyOptions(mode: `fixed` | `random`): { path?: string } { if (mode === `fixed`) { - return { + return oracleFixedSeed(queuedPresenceFixedSeed, { numRuns: oracleRuns(queuedPresenceRuns), - seed: queuedPresenceFixedSeed, - } + }) } return oraclePropertyOptions(queuedPresenceRuns, queuedPresenceProperty) } @@ -2722,8 +2741,10 @@ describe(`Electric adapter laws`, () => { ), )( `preserves live updates after $removals initial tagged move-outs in $syncMode mode`, - ({ syncMode, removals }) => - checkInitialMoveOut(syncMode, removals, 1, `updated`), + ({ syncMode, removals }) => { + oracleRoute(`case`) + return checkInitialMoveOut(syncMode, removals, 1, `updated`) + }, ) electricHistoryProperty( @@ -2758,6 +2779,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `delivers retired callbacks without advancing a failed lifecycle`, async () => { + oracleRoute(`fixed history`) const result = await runProcessGrammar(`process-grammar-terminal-error`, [ { kind: `batch`, @@ -2796,6 +2818,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it).each([`invalid-resume`, `cleanup`] as const)( `records an initial preload's %s rejection before continuing the grammar`, async (failure) => { + oracleRoute(`case`) const result = await runProcessGrammar(`preload-${failure}`, [ { kind: `create`, slot: `a` }, { kind: `import`, slot: `a`, txid: 4, resume: true }, @@ -2842,6 +2865,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `publishes an Electric source commit before its adapter persistence settles`, async () => { + oracleRoute(`case`) await runPublicationBeforePersistenceWitness( `publication-before-persistence-fixed`, { id: 41, name: `visible first`, stable: `stable-41` }, @@ -2853,6 +2877,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `reconstructs the queued-presence grammar and rejects ablated, out-of-range, and foreign campaigns`, () => { + oracleRoute(`fixed seed`) const campaigns = fc.sample(fc.tuple(...queuedPresenceArbitraries), { seed: queuedPresenceFixedSeed, numRuns: 20, @@ -2866,6 +2891,7 @@ describe(`Electric adapter laws`, () => { ) } + oracleRoute(`calibration`) const witness = { histories: [...queuedPresenceHistories], names: [``, `x`.repeat(12)] as [string, string], @@ -2917,6 +2943,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `rejects named wrong answer: later callback publishes before durability`, () => { + oracleRoute(`calibration`) expect(() => expectQueuedPresenceAtDurabilityHold( [ @@ -2930,12 +2957,13 @@ describe(`Electric adapter laws`, () => { }, ) - oracleReplayTest(fcTest).prop( - queuedPresenceArbitraries, - queuedPresencePropertyOptions(`fixed`), - )( + const randomCampaign = queuedPresencePropertyOptions(`fixed`) + oracleReplayTest(fcTest).prop(queuedPresenceArbitraries, randomCampaign)( `generated queued Electric histories preserve staged presence and reset fences (fixed)`, - runQueuedPresenceCampaign, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign)) + return runQueuedPresenceCampaign(...args) + }, ) const publicationPolicyArbitraries: [ @@ -2950,30 +2978,36 @@ describe(`Electric adapter laws`, () => { 800 + id, ) } - oracleReplayTest(fcTest).prop( - publicationPolicyArbitraries, - persistencePolicyPropertyOptions( - `fixed`, - publicationBeforePersistenceProperty, - ), - )( + const randomCampaign2 = persistencePolicyPropertyOptions( + `fixed`, + publicationBeforePersistenceProperty, + ) + oracleReplayTest(fcTest).prop(publicationPolicyArbitraries, randomCampaign2)( `generated Electric commits publish before adapter persistence settles (fixed)`, - checkPublicationPolicy, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign2)) + return checkPublicationPolicy(...args) + }, + ) + const randomCampaign3 = persistencePolicyPropertyOptions( + `random`, + publicationBeforePersistenceProperty, ) oracleReplayTest(fcTest, publicationBeforePersistenceProperty).prop( publicationPolicyArbitraries, - persistencePolicyPropertyOptions( - `random`, - publicationBeforePersistenceProperty, - ), + randomCampaign3, )( `generated Electric commits publish before adapter persistence settles (random)`, - checkPublicationPolicy, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign3)) + return checkPublicationPolicy(...args) + }, ) oracleReplayTest(it)( `turns adapter rejection into a named terminal Electric persistence error`, async () => { + oracleRoute(`case`) await runPersistenceFailureCrashOnlyWitness( `terminal-persistence-error-fixed`, { id: 51, name: `published before failure`, stable: `stable-51` }, @@ -2997,19 +3031,30 @@ describe(`Electric adapter laws`, () => { { id: id + 100, name: names[1], stable: `stable-${id + 100}` }, ) } - oracleReplayTest(fcTest).prop( - failurePolicyArbitraries, - persistencePolicyPropertyOptions(`fixed`, adapterFailurePolicyProperty), - )( + const randomCampaign4 = persistencePolicyPropertyOptions( + `fixed`, + adapterFailurePolicyProperty, + ) + oracleReplayTest(fcTest).prop(failurePolicyArbitraries, randomCampaign4)( `generated adapter failures reject publicly and admit no later Electric work (fixed)`, - checkFailurePolicy, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign4)) + return checkFailurePolicy(...args) + }, + ) + const randomCampaign5 = persistencePolicyPropertyOptions( + `random`, + adapterFailurePolicyProperty, ) oracleReplayTest(fcTest, adapterFailurePolicyProperty).prop( failurePolicyArbitraries, - persistencePolicyPropertyOptions(`random`, adapterFailurePolicyProperty), + randomCampaign5, )( `generated adapter failures reject publicly and admit no later Electric work (random)`, - checkFailurePolicy, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign5)) + return checkFailurePolicy(...args) + }, ) electricHistoryProperty( @@ -3052,6 +3097,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `distinguishes plausible unseen-update and reset-visibility semantics`, async () => { + oracleRoute(`case`) const completeUnseenUpdate: ChangeMessage = { key: `1`, value: { id: 1, name: `complete`, stable: `stable-1` }, @@ -3092,6 +3138,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `keeps SDK reset callbacks separate from every neighboring message kind`, () => { + oracleRoute(`case`) const neighbors: Array> = [ change(`insert`, 1, `row`), change(`update`, 1, `row`), @@ -3124,6 +3171,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `distinguishes callback-atomic and subset publication semantics`, async () => { + oracleRoute(`case`) const callbackHistory = [ [ change(`insert`, 1, `before-control`), @@ -3174,6 +3222,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `settles every startup, hydration, snapshot availability, persistence-apply release, and cleanup permutation`, async () => { + oracleRoute(`case`) const events: Array = [ `startup-promise`, `hydration`, @@ -3443,34 +3492,48 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it).each(publicationEpochExamples)( `reconstructs the authored publication-epoch history`, - assertPublicationEpochHistory, + (...args) => { + oracleRoute(`case`) + return assertPublicationEpochHistory(...args) + }, 30_000, ) + const publicationEpochTokensFixedSeed = oracleFixedSeed(42714, { + numRuns: oracleRuns(20), + }) oracleReplayTest(fcTest).prop( [publicationEpochTokensArbitrary, publicationEpochPartitionArbitrary], - { - seed: 42714, - numRuns: oracleRuns(20), - }, + publicationEpochTokensFixedSeed, )( `Electric drivers converge with the denotational reference and model-fed Query projection across publication epochs (fixed)`, - assertPublicationEpochHistory, + (...args) => { + oracleRoute(oracleRouteOf(publicationEpochTokensFixedSeed)) + return assertPublicationEpochHistory(...args) + }, 30_000, ) + const publicationEpochTokensCampaign = oraclePropertyOptions( + 20, + `electric.publication-epoch-convergence`, + ) oracleReplayTest(fcTest, `electric.publication-epoch-convergence`).prop( [publicationEpochTokensArbitrary, publicationEpochPartitionArbitrary], - oraclePropertyOptions(20, `electric.publication-epoch-convergence`), + publicationEpochTokensCampaign, )( `Electric drivers converge with the denotational reference and model-fed Query projection across publication epochs (random or replayed)`, - assertPublicationEpochHistory, + (...args) => { + oracleRoute(oracleRouteOf(publicationEpochTokensCampaign)) + return assertPublicationEpochHistory(...args) + }, 30_000, ) oracleReplayTest(it)( `checks an authored HTTP transcript and separate complete Query snapshots`, async () => { + oracleRoute(`fixed history`) const { ShapeStream: RealShapeStream } = await vi.importActual<{ ShapeStream: typeof ShapeStream }>(`@electric-sql/client`) @@ -3763,19 +3826,29 @@ describe(`Electric adapter laws`, () => { } } - oracleReplayTest(fcTest).prop([reentryHistory], { - seed: 42713, + const reentryHistoryFixedSeed = oracleFixedSeed(42713, { numRuns: oracleRuns(10), - })( + }) + oracleReplayTest(fcTest).prop([reentryHistory], reentryHistoryFixedSeed)( `replacement sync runs reject evidence from callback reentry histories (fixed)`, - runReentryHistory, + (...args) => { + oracleRoute(oracleRouteOf(reentryHistoryFixedSeed)) + return runReentryHistory(...args) + }, + ) + const reentryHistoryCampaign = oraclePropertyOptions( + 10, + `electric.match-reentry`, ) oracleReplayTest(fcTest, `electric.match-reentry`).prop( [reentryHistory], - oraclePropertyOptions(10, `electric.match-reentry`), + reentryHistoryCampaign, )( `replacement sync runs reject evidence from callback reentry histories (random)`, - runReentryHistory, + (...args) => { + oracleRoute(oracleRouteOf(reentryHistoryCampaign)) + return runReentryHistory(...args) + }, ) /** @@ -3837,7 +3910,7 @@ describe(`Electric adapter laws`, () => { * client awaits the decision; and stream steps after a final failure or an * abort, because the stream has stopped. It also omits a second * up-to-date while the first snapshot's acceptance is pending. The - * installed-client lane cannot hold an acceptance, so this dimension runs + * installed-client receiving path cannot hold an acceptance, so this dimension runs * only here. A progressive history starts with * a must-refetch, so its demands wait on the established snapshot. * @@ -3851,7 +3924,7 @@ describe(`Electric adapter laws`, () => { * defers the Collection's failure (EVID-2; maintainer decisions * 2026-10-30 and 2026-11-02). After cleanup a later run could supply the * evidence, so the late pair is not compared then. The model lives in `electric-retry-decision-model.ts`. An - * installed-client lane in `electric-sdk-delivery-oracle.property.test.ts` + * installed-client receiving path in `electric-sdk-delivery-oracle.property.test.ts` * drives the same model through the real Electric client and HTTP. */ const retryDecisionArb = fc.constantFrom( @@ -4199,7 +4272,7 @@ describe(`Electric adapter laws`, () => { }, 60_000, [ - // Named replays: a retried provider error while a transaction holds the + // Named fixed histories: a retried provider error while a transaction holds the // recovered snapshot, for each wait, with and without a hold (this // replaces the former fixed "renews full recovery demand" case); a // held snapshot whose stream aborts before the hold settles; an @@ -4448,6 +4521,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `does not advance durable Electric resume metadata past a rejected row commit`, async () => { + oracleRoute(`case`) await runRejectedDurablePrefixWitness(`durable-prefix-fixed`, [ `first`, `second`, @@ -4458,6 +4532,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `retains an accepted Electric source prefix when the next row commit rejects`, async () => { + oracleRoute(`case`) await runRejectedDurablePrefixWitness( `durable-prefix-after-success`, [`accepted`, `rejected`], @@ -4476,22 +4551,31 @@ describe(`Electric adapter laws`, () => { names, ) } + const durablePrefixCampaign = durablePrefixPropertyOptions(`fixed`) oracleReplayTest(fcTest).prop( [durablePrefixArbitrary], - durablePrefixPropertyOptions(`fixed`), + durablePrefixCampaign, )( `a rejected initial Electric row commit creates no durable source prefix (fixed)`, - checkDurablePrefix, + (...args) => { + oracleRoute(oracleRouteOf(durablePrefixCampaign)) + return checkDurablePrefix(...args) + }, ) - // A shared seed reseeds this random lane; a path replay selects it by property. + // A shared campaign seed reseeds this random campaign; a path replay selects it by property. + const durablePrefixCampaign2 = durablePrefixPropertyOptions(`random`) oracleReplayTest(fcTest, durablePrefixProperty).prop( [durablePrefixArbitrary], - durablePrefixPropertyOptions(`random`), + durablePrefixCampaign2, )( `a rejected initial Electric row commit creates no durable source prefix (random)`, - checkDurablePrefix, + (...args) => { + oracleRoute(oracleRouteOf(durablePrefixCampaign2)) + return checkDurablePrefix(...args) + }, ) + const fixedCampaign = oracleFixedSeed(20260922, { numRuns: 20 }) oracleReplayTest(fcTest).prop( [ fc.uniqueArray(fc.integer({ min: 1, max: 20 }), { @@ -4499,8 +4583,9 @@ describe(`Electric adapter laws`, () => { maxLength: 6, }), ], - { numRuns: 20, seed: 20260922 }, + fixedCampaign, )(`append-only resume judgment rejects a missing committed row`, (ids) => { + oracleRoute(`fixed seed`) const events: Array = ids.map((id) => ({ type: `source-commit`, offset: `${id}_0`, @@ -4861,9 +4946,11 @@ describe(`Electric adapter laws`, () => { numRuns: 20, }) for (const campaign of campaigns) { + oracleRoute(`fixed seed`) expect(reconstructPersistenceInterleavingCampaign(campaign)).toEqual( campaign, ) + oracleRoute(`calibration`) expect(() => reconstructPersistenceInterleavingCampaign({ names: campaign.names, @@ -4893,6 +4980,7 @@ describe(`Electric adapter laws`, () => { oracleReplayTest(it)( `rejects named wrong answers for persistence interleavings`, () => { + oracleRoute(`calibration`) const names: [string, string] = [`before`, `after`] expect(() => expectPersistenceInterleavingObservation( @@ -4927,29 +5015,36 @@ describe(`Electric adapter laws`, () => { }, ) - oracleReplayTest(fcTest).prop([persistenceInterleavingCampaignArbitrary], { - seed: 18_530_601, + const persistenceInterleavingCampaignFixedSeed = oracleFixedSeed(18_530_601, { numRuns: oracleRuns(4), - })( - `preserves persisted Electric interleavings (fixed)`, - runPersistenceInterleavingCampaign, - ) + }) + oracleReplayTest(fcTest).prop( + [persistenceInterleavingCampaignArbitrary], + persistenceInterleavingCampaignFixedSeed, + )(`preserves persisted Electric interleavings (fixed)`, (...args) => { + oracleRoute(oracleRouteOf(persistenceInterleavingCampaignFixedSeed)) + return runPersistenceInterleavingCampaign(...args) + }) }) -// Each campaign's random lane has its own suite. An ordinary run runs it +// Each random campaign 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) + const persistenceInterleavingCampaignCampaign = oraclePropertyOptions( + 4, + persistenceInterleavingProperty, + ) fcTest.prop( [persistenceInterleavingCampaignArbitrary], - oraclePropertyOptions(4, persistenceInterleavingProperty), - )( - `preserves persisted Electric interleavings (random)`, - runPersistenceInterleavingCampaign, - ) + persistenceInterleavingCampaignCampaign, + )(`preserves persisted Electric interleavings (random)`, (...args) => { + oracleRoute(oracleRouteOf(persistenceInterleavingCampaignCampaign)) + return runPersistenceInterleavingCampaign(...args) + }) }, ) @@ -4958,12 +5053,13 @@ oracleReplayTest(describe, queuedPresenceProperty)( () => { beforeEach(resetElectricOracleMocks) - fcTest.prop( - queuedPresenceArbitraries, - queuedPresencePropertyOptions(`random`), - )( + const randomCampaign6 = queuedPresencePropertyOptions(`random`) + fcTest.prop(queuedPresenceArbitraries, randomCampaign6)( `generated queued Electric histories preserve staged presence and reset fences (random)`, - runQueuedPresenceCampaign, + (...args) => { + oracleRoute(oracleRouteOf(randomCampaign6)) + return runQueuedPresenceCampaign(...args) + }, ) }, ) @@ -5004,4 +5100,5 @@ declareOracle({ queuedPresenceArbitraries, persistenceInterleavingCampaignArbitrary, ], + owns: [`src/electric.ts#electricCollectionOptions`], }) diff --git a/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts b/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts index 2a752391bb..8cab2fd7a2 100644 --- a/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts +++ b/packages/electric-db-collection/tests/electric-progressive-offline-readiness-witness.test.ts @@ -237,7 +237,7 @@ afterEach(() => { }) describe(`persisted progressive Electric query while offline (current behavior)`, () => { - // Observed on main at 31dad2dd3; these pin current behavior, not a law. + // Observed on main at 31dad2dd3; these fix current behavior, not a law. it.each([ [`full-sync`, { resumed: true, ready: true, rows: [1, 2] }], [`subset-only`, { resumed: false, ready: false, rows: [1, 2] }], diff --git a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts index 70a7407e63..4d474979a1 100644 --- a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts @@ -4,7 +4,13 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { ShapeStream } from '@electric-sql/client' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { SQLiteCorePersistenceAdapter, @@ -762,6 +768,7 @@ declareOracle({ expectedReplacementRows, ], grammar: [publicationHistoryArbitrary], + owns: [`src/electric.ts#electricCollectionOptions`], }) describe(`persisted Electric recovery laws`, () => { @@ -771,6 +778,7 @@ describe(`persisted Electric recovery laws`, () => { it.each([`insert`, `update`] as const)( `models a same-key %s against the durable row operation`, async (operation) => { + oracleRoute(`case`) const f = fixture(`on-demand`) await withFixtureCleanup(f, async () => { const old = { ...freshRow, obsolete: `old field` } @@ -820,6 +828,7 @@ describe(`persisted Electric recovery laws`, () => { it.each(persistedRestartScenarios)( `couples $resumeKind state to $transition with $rowState rows and $sourceHistory source history`, async (scenario) => { + oracleRoute(`case`) const { cleanupEvidence } = await observePersistedRestart(scenario) expect(cleanupEvidence).toContain(`passed: committed retired-stream`) }, @@ -870,13 +879,20 @@ describe(`persisted Electric recovery laws`, () => { it.each(publicationHistoryExamples)( `reconstructs the authored persistence publication history`, - assertPublicationHistory, + (commands) => { + oracleRoute(`fixed history`) + return assertPublicationHistory(commands) + }, ) it(`fixed recovery corpus reaches every deletion and reload combination`, () => { + oracleRoute(`fixed seed`) const combinations = new Set( fc - .sample(publicationHistoryArbitrary, { seed: 1659, numRuns: 20 }) + .sample( + publicationHistoryArbitrary, + oracleFixedSeed(1659, { numRuns: 20 }), + ) .flat() .map(({ deleted, fullReload }) => `${deleted}:${fullReload}`), ) @@ -885,28 +901,33 @@ describe(`persisted Electric recovery laws`, () => { ) }) - fcTest.prop([publicationHistoryArbitrary], { - seed: 1659, + const publicationHistoryFixedSeed = oracleFixedSeed(1659, { numRuns: oracleRuns(20), - })( + }) + fcTest.prop([publicationHistoryArbitrary], publicationHistoryFixedSeed)( `independent persistence publications and stream deltas agree with complete-row state (fixed)`, - assertPublicationHistory, + (commands) => { + oracleRoute(oracleRouteOf(publicationHistoryFixedSeed)) + return assertPublicationHistory(commands) + }, ) - fcTest.prop( - [publicationHistoryArbitrary], - oraclePropertyOptions( - 20, - `electric-recovery.publication-stream-convergence`, - ), - )( + const publicationHistoryCampaign = oraclePropertyOptions( + 20, + `electric-recovery.publication-stream-convergence`, + ) + fcTest.prop([publicationHistoryArbitrary], publicationHistoryCampaign)( `independent persistence publications and stream deltas agree with complete-row state (random or replayed)`, - assertPublicationHistory, + (commands) => { + oracleRoute(oracleRouteOf(publicationHistoryCampaign)) + return assertPublicationHistory(commands) + }, ) it.each(scenarios)( `$syncMode invalid resume replaces omitted cached rows: empty=$empty, hydration=$hydration callback`, async ({ syncMode, empty, hydration }) => { + oracleRoute(`case`) const f = fixture(syncMode) const gate = deferred() await withFixtureCleanup( diff --git a/packages/electric-db-collection/tests/electric-retry-decision-model.ts b/packages/electric-db-collection/tests/electric-retry-decision-model.ts index 4da43d636e..89231958d1 100644 --- a/packages/electric-db-collection/tests/electric-retry-decision-model.ts +++ b/packages/electric-db-collection/tests/electric-retry-decision-model.ts @@ -1,7 +1,7 @@ /** * The independent model for the Electric retry-decision law. It is shared by * the retry-decision oracle in `electric-oracle.property.test.ts`, which - * drives a controlled ShapeStream, and by the installed-client lane in + * drives a controlled ShapeStream, and by the installed-client receiving path in * `electric-sdk-delivery-oracle.property.test.ts`. It reads no production * state. The law and its history grammar are documented beside the * retry-decision oracle. @@ -282,7 +282,7 @@ export function createRetryModel( wait: RetryWait | boolean, cleanupOnError = false, ): RetryModel { - // The boolean form is the installed-client lane's full-log wait (`false`) + // The boolean form is the installed-client path's full-log wait (`false`) // or a progressive wait (`true`). const resolved: RetryWait = typeof wait === `boolean` ? (wait ? `progressive` : `full-log`) : wait 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 a0f225bad5..827eccb509 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 @@ -10,8 +10,11 @@ import { ShapeStream } from '@electric-sql/client' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { electricCollectionOptions } from '../src/electric' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' @@ -52,7 +55,7 @@ import type { Message } from '@electric-sql/client' import type * as ElectricSdkModule from '@electric-sql/client' // Observe the real constructor boundary without changing the installed SDK's -// options, prototype, methods, or HTTP behavior. Only the rotation lane enables +// options, prototype, methods, or HTTP behavior. Only the rotation grammar enables // the callback; every construction still executes the original ShapeStream. const sdkConstruction = vi.hoisted(() => ({ observe: undefined as (() => void) | undefined, @@ -105,7 +108,7 @@ vi.mock(`@electric-sql/client`, async (importOriginal) => { * A real node:sqlite cold-launch witness starts with an incompatible partial * cache and no reset marker. It must exclude that row before and after the * installed SDK returns an empty source snapshot for the demanded predicate. - * A source-release lane, documented at its property, checks that cleanup + * A source-release grammar, documented at its property, checks that cleanup * removes every listener that a run's streams registered on `document`. * * These finite HTTP fixtures do not prove that a live Electric service emits @@ -150,6 +153,7 @@ declareOracle({ expectedRetryObservation, expectedResumeCheckpoint, ], + owns: [`src/electric.ts#electricCollectionOptions`], }) // Scaled with the run multiplier, so stress campaigns keep their budget. @@ -704,15 +708,18 @@ sdkCampaigns( snapshotViolation, async (seed) => { snapshotReach = createSnapshotReach() - await fc.assert(snapshotProperty, { + const options = { ...oraclePropertyOptions(40, `electric.sdk-snapshot-delivery`), - ...(seed === undefined ? {} : { seed }), - }) + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(options)) + await fc.assert(snapshotProperty, options) snapshotReach.assertReached() }, ) fixedCase(`detects dropped snapshot rows`, async () => { + oracleRoute(`calibration`) await expect(checkSnapshots(2, `payload`, true)).rejects.toMatchObject({ name: `AssertionError`, message: expect.stringContaining(`snapshot 0 applied rows`), @@ -722,6 +729,7 @@ fixedCase(`detects dropped snapshot rows`, async () => { fixedCase( `retains the snapshot law and checkpoint when cleanup also fails`, async () => { + oracleRoute(`calibration`) const cleanupFailure = new Error(`injected cleanup failure`) const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) const subscribe = ShapeStream.prototype.subscribe @@ -758,6 +766,7 @@ fixedCase( fixedCase( `restores the SDK setup resources after construction fails`, async () => { + oracleRoute(`case`) const primary = new Error(`collection construction failed`) const secondary = new Error(`provider release failed`) const subscribe = ShapeStream.prototype.subscribe @@ -965,14 +974,18 @@ sdkCampaigns( `preserves DNF visibility through installed-SDK silent move-in cycles`, 20260918, membershipViolation, - (seed) => - fc.assert(membershipProperty, { + (seed) => { + const options = { ...oraclePropertyOptions(12, `electric.sdk-dnf-membership`), - ...(seed === undefined ? {} : { seed }), - }), + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(options)) + return fc.assert(membershipProperty, options) + }, ) fixedCase(`detects dropped move-in at its visibility checkpoint`, async () => { + oracleRoute(`calibration`) await expect(checkMembership(1, 1, 1, true)).rejects.toMatchObject({ name: `AssertionError`, message: expect.stringContaining(`after silent move-in`), @@ -983,7 +996,7 @@ fixedCase(`detects dropped move-in at its visibility checkpoint`, async () => { * # The retry-decision law through the installed Electric client * * The retry-decision oracle in `electric-oracle.property.test.ts` drives the - * adapter through a controlled ShapeStream. This lane receives the same law + * adapter through a controlled ShapeStream. This receiving path receives the same law * through the installed `@electric-sql/client`, its retry policy, and HTTP. * It uses the same independent model (`electric-retry-decision-model.ts`). * @@ -1007,7 +1020,7 @@ fixedCase(`detects dropped move-in at its visibility checkpoint`, async () => { * load. An eager or progressive Collection has no subset demand before its * first ready, so its demand is the readiness wait (`preload()`). * - * At each checkpoint the lane compares the Collection status, each demand's + * At each checkpoint the receiving path compares the Collection status, each demand's * settlement, whether the snapshot's row is visible, and two pairs of waits * that no evidence satisfies, one started with the history and one started * at the checkpoint, with the model: @@ -1223,7 +1236,10 @@ async function runClientRetryCell( fixedCase.each(clientRetryCells)( `installed client: %s in %s mode with an onError %s follows the retry-decision model`, - (outcome, mode, decision) => runClientRetryCell(outcome, mode, decision), + (outcome, mode, decision) => { + oracleRoute(`case`) + return runClientRetryCell(outcome, mode, decision) + }, 60_000, ) @@ -1252,7 +1268,7 @@ fixedCase.each(clientRetryCells)( * retired HTTP snapshot arrives while rotation is held, just before replacement * snapshots, or after replacement settlement. A forwarding constructor observer * records listener ownership before invoking the real SDK. It does not release - * resources or replace SDK behavior. This lane + * resources or replace SDK behavior. This receiving path * covers sequential rotations in an on-demand run with the installed browser * SDK; overlapping rotations, other SDK versions, and native hosts remain separate. * Both retained demands must remain pending until replacement source responses @@ -1588,9 +1604,10 @@ async function checkManagedRotation(cuts: ReadonlyArray) { ]) } -fixedCase(`discards an old SDK snapshot after managed cache rotation`, () => - checkManagedRotation([`after-snapshots`]), -) +fixedCase(`discards an old SDK snapshot after managed cache rotation`, () => { + oracleRoute(`case`) + return checkManagedRotation([`after-snapshots`]) +}) const rotationViolation = sameViolation() sdkCampaigns( `electric.sdk-rotation-release`, @@ -1599,6 +1616,11 @@ sdkCampaigns( rotationViolation, async (seed) => { rotationReach = createRotationReach() + const options = { + ...oraclePropertyOptions(40, `electric.sdk-rotation-release`), + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(options)) await fc.assert( fc.asyncProperty( fc.array( @@ -1612,10 +1634,7 @@ sdkCampaigns( (cuts) => rotationViolation.check(cuts, () => checkManagedRotation(cuts)), ), - { - ...oraclePropertyOptions(40, `electric.sdk-rotation-release`), - ...(seed === undefined ? {} : { seed }), - }, + options, ) rotationReach.assertReached() }, @@ -1670,10 +1689,10 @@ sdkCampaigns( * bound and one-registration-per-run calibration describe this installed SDK * and the driver's one-stream topology, rather than a universal SDK policy. * - * Limits: the lane observes the browser listener in jsdom. Client versions + * Limits: the receiving path observes the browser listener in jsdom. Client versions * that observe React Native's AppState instead register through a runtime - * adapter, which this lane does not install. A live Electric service and - * snapshot requests are outside this lane. The managed cache rotation case + * adapter, which this receiving path does not install. A live Electric service and + * snapshot requests are outside this receiving path. The managed cache rotation case * above replaces a provider session within one run; it receives the same law * for the retired session. */ @@ -1882,6 +1901,7 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { fixedCase.each([`eager`, `on-demand`, `progressive`] as const)( `releases listeners after incomplete delivery in %s mode`, async (mode) => { + oracleRoute(`case`) for (const answer of [`row`, `reset`] as const) for (const end of [ `open`, @@ -1934,10 +1954,12 @@ sdkCampaigns( releaseViolation, async (seed) => { releaseReach = createReleaseReach() - await fc.assert(releaseProperty, { + const options = { ...oraclePropertyOptions(40, `electric.sdk-source-release`), - ...(seed === undefined ? {} : { seed }), - }) + ...(seed === undefined ? {} : oracleFixedSeed(seed)), + } + oracleRoute(oracleRouteOf(options)) + await fc.assert(releaseProperty, options) releaseReach.assertReached() }, ) 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 2f0ff9d36f..cfdbc10ef0 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,7 +1,12 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { serialize } from '../src/pg-serializer' -import { oracleReplayTest, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oracleReplayTest, + oracleRoute, + oracleRuns, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { parseFiniteArray, @@ -60,7 +65,10 @@ function expectFiniteArrayContents( expect(parseFiniteArray(output)).toEqual(input.map((value) => value ?? null)) } -declareOracle({ check: [expectFiniteArrayContents] }) +declareOracle({ + check: [expectFiniteArrayContents], + owns: [`src/pg-serializer.ts#serialize`], +}) describe(`pg-serializer property-based tests`, () => { describe(`string serialization`, () => { @@ -297,6 +305,7 @@ describe(`pg-serializer property-based tests`, () => { example.each(validArrays)( `decodes the independent $name control and the real serializer`, ({ input, output }) => { + oracleRoute(`case`) expectFiniteArrayContents(input, output) expectFiniteArrayContents(input, serialize(input)) }, @@ -346,17 +355,22 @@ describe(`pg-serializer property-based tests`, () => { { name: `numeric suffix`, input: [1.25], output: `{1.25junk}` }, ] example.each(corruptArrays)(`rejects $name`, ({ input, output }) => { + oracleRoute(`calibration`) // The same observer judges production outputs above; these outputs come // from test-owned faults, not a mutation of the serializer or its inputs. expect(() => expectFiniteArrayContents(input, output)).toThrow() }) example(`shrinks and exactly replays omitted nonempty arrays`, () => { + oracleRoute(`calibration`) const property = fc.property( fc.array(fc.boolean(), { minLength: 1, maxLength: 10 }), (input) => expectFiniteArrayContents(input, `{}`), ) - const failure = fc.check(property, { seed: 90701, numRuns: 25 }) + const failure = fc.check( + property, + oracleFixedSeed(90701, { numRuns: 25 }), + ) expect(failure.failed).toBe(true) if (!failure.failed || failure.counterexamplePath === null) { throw new Error(`omission fault did not produce a replayable failure`) diff --git a/packages/electric-db-collection/tests/pg-serializer-property-support.ts b/packages/electric-db-collection/tests/pg-serializer-property-support.ts index a5d981ca4b..3f990cab67 100644 --- a/packages/electric-db-collection/tests/pg-serializer-property-support.ts +++ b/packages/electric-db-collection/tests/pg-serializer-property-support.ts @@ -1,7 +1,10 @@ import { test as fcTest } from '@fast-check/vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import type { fc } from '@fast-check/vitest' @@ -21,13 +24,23 @@ export function serializerProp( registeredProperties.add(name) // 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], { + const fixedCampaign = oracleFixedSeed(20260930, { numRuns: oracleRuns(100), - seed: 20260930, - })(`${name} (fixed)`, check) - oracleReplayTest(fcTest, property).prop( - [arbitrary], - oraclePropertyOptions(100, property), - )(`${name} (random)`, check) + }) + oracleReplayTest(fcTest).prop([arbitrary], fixedCampaign)( + `${name} (fixed)`, + (value) => { + oracleRoute(oracleRouteOf(fixedCampaign)) + check(value) + }, + ) + const randomCampaign = oraclePropertyOptions(100, property) + oracleReplayTest(fcTest, property).prop([arbitrary], randomCampaign)( + `${name} (random)`, + (value) => { + oracleRoute(oracleRouteOf(randomCampaign)) + check(value) + }, + ) } } diff --git a/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts index ec62d0399d..c1cda01dde 100644 --- a/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts +++ b/packages/electric-db-collection/tests/schema-transform-conformance.test-d.ts @@ -50,7 +50,7 @@ const synchronizedOutput = { * at `string | number`; it does not infer the schema's branded ID type. * * The assertions observe those production type paths after overload - * resolution. Hostile controls reject an untransformed shape row at the sync + * resolution. Mutant controls reject an untransformed shape row at the sync * boundary and an invalid value at the mutation boundary. This partial oracle * does not execute Shape parsing, network I/O, or mutation-handler timing. */ diff --git a/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts b/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts index 885e371362..7e5dcb1ccc 100644 --- a/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts +++ b/packages/electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { persistedCollectionOptions } from '@tanstack/db-sqlite-persistence-core' import { createNodeSQLitePersistence } from '@tanstack/node-db-sqlite-persistence' +import { oracleRoute } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' import { IR, @@ -59,7 +60,7 @@ import type { * reply, which is the only part of the IPC transport that this law depends * on. With `TANSTACK_DB_ELECTRON_E2E_ALL=1`, each envelope instead crosses a * real Electron main process through the runtime bridge. The histories cover - * a renderer without a coordinator. A second lane below runs a follower and + * a renderer without a coordinator. A second production path below runs a follower and * an owner renderer with coordinators. */ @@ -253,7 +254,7 @@ type Checkpoints = { // before it is ever ready. A demand released while its hydration is in flight // starts no live upstream load, installs no upstream rows, and causes no // error. Whether locally hydrated rows stay is retention, which the persisted -// oracle's retained-demand lane owns, so the model does not predict it. +// oracle's retained-demand grammar owns, so the model does not predict it. function expectedCheckpoints(history: IpcReadinessHistory): Checkpoints { const local = history.renderer === `reopened` ? [`local`] : [] if (history.hydration === `fails`) @@ -398,6 +399,10 @@ async function compareIpcReadinessHistory( } declareOracle({ + owns: [ + `src/renderer.ts#createElectronSQLitePersistence`, + `src/electron-coordinator.ts`, + ], check: [compareIpcReadinessHistory], model: [expectedCheckpoints], }) @@ -406,6 +411,7 @@ describe(`Electron renderer initial readiness follows the composed load`, () => it.each(ipcReadinessHistories)( `matches the model: $renderer renderer, hydration $hydration, then $next`, async (history) => { + oracleRoute(`case`) await compareIpcReadinessHistory(history) }, historyTimeout, @@ -466,7 +472,7 @@ function coordinatedRenderer(options: { trace.push(`ensure-settled`), ) } - // Record when the owner's commit notice reaches this renderer, so the lane + // Record when the owner's commit notice reaches this renderer, so the test // can check the premise it relies on: the notice precedes the reply. const subscribe = coordinator.subscribe.bind(coordinator) coordinator.subscribe = (collectionId, onMessage) => @@ -597,7 +603,7 @@ async function observeCoordinatedRendererHistory( follower.trace.indexOf(`ensure-settled`), ) const settledWhileApplyHeld = settledAt !== undefined - // The topology this lane claims: the owner loads upstream, and the + // The topology this test claims: the owner loads upstream, and the // follower routes its demand instead of running its own source. expect(owner.trace).toContain(`upstream`) expect(follower.trace).not.toContain(`upstream`) @@ -644,6 +650,7 @@ describe(`Electron follower renderer settles with its owner's rows`, () => { )( `matches the model: apply $apply, consumer $consumer`, async (history) => { + oracleRoute(`case`) await compareCoordinatedRendererHistory(history) }, isElectronFullE2EEnabled() ? 180_000 : 20_000, diff --git a/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.electron.e2e.test.ts b/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.electron.e2e.test.ts index cd391c26fc..0c94f11bb4 100644 --- a/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.electron.e2e.test.ts +++ b/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.electron.e2e.test.ts @@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url' import { build } from 'esbuild' import electronPath from 'electron' import { expect, it, onTestFinished } from 'vitest' +import { oracleRoute } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' const fixture = join( @@ -140,9 +141,17 @@ function expectReloadClassLawsHold(result: Result): void { ).toEqual([]) } -declareOracle({ check: [expectReloadClassLawsHold] }) +declareOracle({ + owns: [ + `src/main.ts#exposeElectronSQLitePersistence`, + `src/renderer.ts#createElectronSQLitePersistence`, + `src/electron-coordinator.ts`, + ], + check: [expectReloadClassLawsHold], +}) it(`real Electron reload is free of avoidable work ahead of its reads`, async () => { + oracleRoute(`fixed history`) const out = mkdtempSync(join(tmpdir(), `reload-class-app-`)) const databases = mkdtempSync(join(tmpdir(), `reload-class-db-`)) onTestFinished(() => { diff --git a/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.test.ts b/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.test.ts index 038238a13b..5d42908c25 100644 --- a/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.test.ts +++ b/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.test.ts @@ -21,7 +21,13 @@ import { test as fcTest } from '@fast-check/vitest' import { describe, expect, onTestFinished } from 'vitest' import { createNodeSQLitePersistence } from '@tanstack/node-db-sqlite-persistence' import { BetterSqlite3SQLiteDriver } from '../../node-db-sqlite-persistence/src/node-driver' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRuns, +} from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { fixedReloadHistories, inProcessSeams, @@ -205,20 +211,37 @@ async function expectLawful(history: ReloadHistory) { expect(violations).toEqual([]) } +declareOracle({ + owns: [ + `src/main.ts#exposeElectronSQLitePersistence`, + `src/renderer.ts#createElectronSQLitePersistence`, + `src/electron-coordinator.ts`, + ], + check: [expectLawful], + grammar: [reloadHistoryArb], +}) + describe(`reload class oracle (Electron driver)`, () => { fcTest.each( fixedReloadHistories.map(([name, history]) => ({ name, history })), )( `keeps a reload free of avoidable work: $name`, async ({ history }) => { + oracleRoute(`fixed history`) await expectLawful(history) }, 30_000, ) - fcTest.prop([reloadHistoryArb], { seed: 2205, numRuns: oracleRuns(12) })( + fcTest.prop( + [reloadHistoryArb], + oracleFixedSeed(2205, { numRuns: oracleRuns(12) }), + )( `keeps generated reloads lawful (fixed seed)`, - expectLawful, + (history) => { + oracleRoute(`fixed seed`) + return expectLawful(history) + }, 300_000, ) @@ -227,7 +250,10 @@ describe(`reload class oracle (Electron driver)`, () => { oraclePropertyOptions(12, `sqlite-reload.class.electron`), )( `keeps generated reloads lawful (random or replayed)`, - expectLawful, + (history) => { + oracleRoute(`random seed`) + return expectLawful(history) + }, 300_000, ) }) diff --git a/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts b/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts index af3d6a4802..5c298effcc 100644 --- a/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts +++ b/packages/expo-db-sqlite-persistence/e2e/expo-persisted-demand-coverage.e2e.test.ts @@ -20,7 +20,7 @@ * Observations: the records of both sessions, judged by the shared judge * (`persisted-demand-coverage-model-oracle.ts`) at each session 2 checkpoint. * - * Limits: No device runs here. The helper backs the driver with better-sqlite3 unless a runtime factory is supplied (`TANSTACK_DB_MOBILE_SQLITE_FACTORY_MODULE`). The emulator runtime lane (`TANSTACK_DB_EXPO_RUNTIME_PLATFORM`) skips this witness, as it skips the conformance suite, because that lane runs the native driver through its own app. The sessions are two driver instances in one process over one file, not an app restart. + * Limits: No device runs here. The helper backs the driver with better-sqlite3 unless a runtime factory is supplied (`TANSTACK_DB_MOBILE_SQLITE_FACTORY_MODULE`). The emulator runtime production path (`TANSTACK_DB_EXPO_RUNTIME_PLATFORM`) skips this witness, as it skips the conformance suite, because that production path runs the native driver through its own app. The sessions are two driver instances in one process over one file, not an app restart. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -44,7 +44,7 @@ afterEach(() => { const DESCRIBE = `expo persisted demand coverage (receiving witness)` -// The emulator lane runs the native driver; this witness runs the shimmed driver. +// The emulator production path runs the native driver; this witness runs the shimmed driver. const runtimePlatform = process.env.TANSTACK_DB_EXPO_RUNTIME_PLATFORM?.trim() const describeShimmedDriver = runtimePlatform ? describe.skip : describe 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 864c524516..3337bf5de2 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 @@ -71,8 +71,11 @@ import { decodePersistedStorageKey, } from '@tanstack/db-sqlite-persistence-core' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -147,15 +150,14 @@ type GeneratedPropertyCampaign = { parameters: ReturnType } -// 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 +// The fixed campaign fixes its campaign seed and stands aside on every path replay. The +// random campaign runs in ordinary and reseeded campaigns, and as the replay of // its property, where oraclePropertyOptions returns the recorded path. const fixedGeneratedCampaign: GeneratedPropertyCampaign = { name: `fixed`, - parameters: { - seed: DEFAULT_ORACLE_SEED, + parameters: oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: oracleRuns(DEFAULT_ORACLE_RUNS), - }, + }), } function randomGeneratedCampaign(property: string): GeneratedPropertyCampaign { @@ -650,11 +652,13 @@ async function runGeneratedScenarioProperty( campaign: GeneratedPropertyCampaign, ) { const reachedScenarios = new Set() + const parameters = generatedCampaignParameters(campaign) + oracleRoute(oracleRouteOf(parameters)) const details = await fc.check( generatedScenarioProperty({ onScenario: (kind) => reachedScenarios.add(kind), }), - generatedCampaignParameters(campaign), + parameters, ) if (details.failed) { @@ -904,6 +908,7 @@ function expectedNativePolarityKeys( } declareOracle({ + owns: [`src/node-driver.ts`], check: [assertExpressionIndexHistory], model: [expectedKeysFromScan, expectedRangeKeys, expectedNativePolarityKeys], grammar: [generatedScenarioMatrixArbitrary], @@ -913,6 +918,7 @@ describe(`SQLite expression-index oracle`, () => { const it = oracleReplayTest(vitestIt) it(`keeps large Number matches across BigInt range directions and boolean wrappers`, async () => { + oracleRoute(`case`) const field = new IR.PropRef([`n`]) const cases = [ { @@ -980,6 +986,8 @@ describe(`SQLite expression-index oracle`, () => { }) async function roundTripGeneratedBigInt(campaign: GeneratedPropertyCampaign) { + const campaignParameters = generatedCampaignParameters(campaign) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.bigInt({ min: SQLITE_BIGINT_MIN, max: SQLITE_BIGINT_MAX }), @@ -1022,7 +1030,7 @@ describe(`SQLite expression-index oracle`, () => { }, [() => driver.close()]) }, ), - generatedCampaignParameters(campaign), + campaignParameters, ) } oracleReplayTest(vitestIt).each([fixedGeneratedCampaign])( @@ -1046,6 +1054,8 @@ describe(`SQLite expression-index oracle`, () => { .map((distance) => SQLITE_BIGINT_MAX + distance), ) + const runParameters = generatedCampaignParameters(campaign) + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty(outOfRangeBigIntArbitrary, async (value) => { const driver = new BetterSqlite3SQLiteDriver({ filename: `:memory:` }) @@ -1075,7 +1085,7 @@ describe(`SQLite expression-index oracle`, () => { ).rejects.toThrow(bigintRangeError(value)) }, [() => driver.close()]) }), - generatedCampaignParameters(campaign), + runParameters, ) } oracleReplayTest(vitestIt).each([fixedGeneratedCampaign])( @@ -1090,6 +1100,7 @@ describe(`SQLite expression-index oracle`, () => { ) it(`uses the expression index for the fixed filter witness`, async () => { + oracleRoute(`fixed history`) await assertExpressionIndexHistory( { path: [`threadId`], @@ -1103,6 +1114,7 @@ describe(`SQLite expression-index oracle`, () => { async function reachGeneratedExpressionIndex( campaign: GeneratedPropertyCampaign, ) { + oracleRoute(campaign.name === `fixed` ? `fixed seed` : `random seed`) const details = await runGeneratedScenarioProperty(campaign) const parameters = generatedCampaignParameters(campaign) @@ -1133,34 +1145,34 @@ describe(`SQLite expression-index oracle`, () => { generatedControlTest.each(GENERATED_SCENARIO_KINDS)( `rejects the generated grammar when the %s axis is ablated`, async (kind) => { + oracleRoute(`calibration`) const details = await fc.check( generatedScenarioProperty({ ablateKind: kind }), - { - seed: DEFAULT_ORACLE_SEED, - numRuns: 1, - verbose: 2, - }, + oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: 1, verbose: 2 }), ) const evidence = JSON.stringify({ seed: details.seed, path: details.counterexamplePath, - error: details.error, + error: String(details.errorInstance), }) expect(details.failed, evidence).toBe(true) expect(details.seed, evidence).toBe(DEFAULT_ORACLE_SEED) expect(details.counterexamplePath, evidence).not.toBeNull() - expect(details.error, evidence).toContain(`missing=${kind}`) + expect(String(details.errorInstance), evidence).toContain( + `missing=${kind}`, + ) }, ) generatedControlTest( `reconstructs the retained generated grammar and rejects a nearby duplicate axis`, () => { - const [matrix] = fc.sample(generatedScenarioMatrixArbitrary, { - seed: DEFAULT_ORACLE_SEED, - numRuns: 1, - }) + oracleRoute(`calibration`) + const [matrix] = fc.sample( + generatedScenarioMatrixArbitrary, + oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: 1 }), + ) if (!matrix) throw new Error(`fixed grammar witness was not generated`) assertGeneratedScenarioGrammar(matrix) expect(GENERATED_SCENARIO_FAULTS.map(({ kind }) => kind)).toEqual( @@ -1179,25 +1191,26 @@ describe(`SQLite expression-index oracle`, () => { generatedControlTest.each(GENERATED_SCENARIO_FAULTS)( `rejects the plausible $wrongAnswer for the $kind axis`, async ({ kind }) => { + oracleRoute(`calibration`) const details = await fc.check( generatedScenarioProperty({ faultKind: kind }), - { - seed: DEFAULT_ORACLE_SEED, - numRuns: 1, - verbose: 2, - }, + oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: 1, verbose: 2 }), ) const evidence = JSON.stringify({ seed: details.seed, path: details.counterexamplePath, - error: details.error, + error: String(details.errorInstance), }) expect(details.failed, evidence).toBe(true) expect(details.seed, evidence).toBe(DEFAULT_ORACLE_SEED) expect(details.counterexamplePath, evidence).not.toBeNull() - expect(details.error ?? ``, evidence).not.toContain(`did not reach`) - expect(details.error, evidence).toContain(`"kind": "${kind}"`) + expect(String(details.errorInstance ?? ``), evidence).not.toContain( + `did not reach`, + ) + expect(String(details.errorInstance), evidence).toContain( + `"kind": "${kind}"`, + ) if (details.counterexamplePath === null) { throw new Error(`${kind} wrong-answer control did not shrink to a path`) @@ -1215,11 +1228,15 @@ describe(`SQLite expression-index oracle`, () => { seed: replay.seed, requestedPath: details.counterexamplePath, replayPath: replay.counterexamplePath, - error: replay.error, + error: String(replay.errorInstance), }) expect(replay.failed, replayEvidence).toBe(true) - expect(replay.error ?? ``, replayEvidence).not.toContain(`did not reach`) - expect(replay.error, replayEvidence).toContain(`"kind": "${kind}"`) + expect(String(replay.errorInstance ?? ``), replayEvidence).not.toContain( + `did not reach`, + ) + expect(String(replay.errorInstance), replayEvidence).toContain( + `"kind": "${kind}"`, + ) }, ) }) @@ -1231,6 +1248,7 @@ describe(`Temporal expression-index refinement`, () => { it.each(temporalFamilies)( `uses native keys and identity for $kind / $name`, async (family) => { + oracleRoute(`case`) vi.stubGlobal(`Temporal`, Temporal) try { for (const query of temporalQueryCases(family)) { @@ -1287,6 +1305,7 @@ describe(`Temporal expression-index refinement`, () => { it.each(temporalFamilies)( `retains mixed-domain matches under native predicate polarity / $name`, async (family) => { + oracleRoute(`case`) vi.stubGlobal(`Temporal`, Temporal) try { const ref = new IR.PropRef([`stamp`]) diff --git a/packages/node-db-sqlite-persistence/tests/expression-index.test.ts b/packages/node-db-sqlite-persistence/tests/expression-index.test.ts index 316d36ba75..5fbef18a5c 100644 --- a/packages/node-db-sqlite-persistence/tests/expression-index.test.ts +++ b/packages/node-db-sqlite-persistence/tests/expression-index.test.ts @@ -80,7 +80,7 @@ describe(`SQLite expression-index oracle`, () => { expect(readOracleRunConfig({ TANSTACK_DB_ORACLE_SEED: `1659005` })).toEqual( { multiplier: 1, - replaySeed: 1659005, + campaignSeed: 1659005, replayPath: undefined, replayProperty: undefined, }, @@ -93,7 +93,7 @@ describe(`SQLite expression-index oracle`, () => { }), ).toEqual({ multiplier: 1, - replaySeed: -1659005, + campaignSeed: -1659005, replayPath: `0:1:2`, replayProperty: `sqlite-persistence.expression-index`, }) @@ -1763,7 +1763,7 @@ describe(`SQLite expression-index oracle`, () => { .all(jsonPath, target) as Array // Same correct rows, different plan: a row-only assertion is - // false-green. This predicate-plan mismatch kills the hostile mutant. + // false-green. This predicate-plan mismatch kills the mutant. expect(boundRows).toEqual(literalRows) expect(planUsesNamedIndex(literalPlan, `rows`, `literal_ddl`)).toBe(true) expect(planScansTable(literalPlan, `rows`)).toBe(false) diff --git a/packages/offline-transactions/src/executor/KeyScheduler.ts b/packages/offline-transactions/src/executor/KeyScheduler.ts index 2502becadb..6edd95cadc 100644 --- a/packages/offline-transactions/src/executor/KeyScheduler.ts +++ b/packages/offline-transactions/src/executor/KeyScheduler.ts @@ -5,11 +5,26 @@ export class KeyScheduler { private activeTransactionId: string | undefined schedule(transaction: OfflineTransaction): boolean { - if ( - this.pendingTransactions.some((pending) => pending.id === transaction.id) - ) { - return false - } + // Development-only oracle probes read this. The published build removes + // this guard and its probes (scripts/strip-oracle-probes.mjs). A bundler + // that sets NODE_ENV and bundles these sources folds it to `false` and + // removes them; the typeof check keeps an unbundled module from throwing + // where `process` does not exist. + const probing = + typeof process === `undefined` + ? false + : process.env.NODE_ENV !== `production` + const duplicate = this.pendingTransactions.some( + (pending) => pending.id === transaction.id, + ) + // Development-only oracle probe; a production build removes it. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`offline-transactions#duplicate-schedule`, duplicate) + if (duplicate) return false this.pendingTransactions.push(transaction) // Sort by creation time to maintain FIFO order this.pendingTransactions.sort( diff --git a/packages/offline-transactions/src/executor/TransactionExecutor.ts b/packages/offline-transactions/src/executor/TransactionExecutor.ts index f8d788719d..ff13791a6d 100644 --- a/packages/offline-transactions/src/executor/TransactionExecutor.ts +++ b/packages/offline-transactions/src/executor/TransactionExecutor.ts @@ -176,6 +176,15 @@ export class TransactionExecutor { transaction: OfflineTransaction, error: Error, ): Promise { + // Development-only oracle probes read this. The published build removes + // this guard and its probes (scripts/strip-oracle-probes.mjs). A bundler + // that sets NODE_ENV and bundles these sources folds it to `false` and + // removes them; the typeof check keeps an unbundled module from throwing + // where `process` does not exist. + const probing = + typeof process === `undefined` + ? false + : process.env.NODE_ENV !== `production` let shouldRetry: boolean let hookFailure: Error | undefined if (error instanceof NonRetriableError) { @@ -224,6 +233,16 @@ export class TransactionExecutor { } else { console.warn(`Transaction ${transaction.id} failed permanently:`, error) } + // Development-only oracle probe; a production build removes it. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `offline-transactions#permanent-failure-before-peer`, + this.scheduler.getPendingCount() > 1, + ) try { await this.removeSettledTransaction(rejectionPending, true) } catch (storageError) { @@ -250,6 +269,16 @@ export class TransactionExecutor { }, } + // Development-only oracle probe; a production build removes it. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `offline-transactions#retry-before-peer`, + this.scheduler.getPendingCount() > 1, + ) this.scheduler.updateTransaction(updatedTransaction) try { @@ -273,19 +302,44 @@ export class TransactionExecutor { transaction: OfflineTransaction, persistMarker: boolean, ): Promise { + // Read by this function's development-only oracle probes, as above. + const probing = + typeof process === `undefined` + ? false + : process.env.NODE_ENV !== `production` try { if (persistMarker) { try { await this.outbox.update(transaction.id, transaction) } catch (error) { // A public removal can finish while the provider is still running. - if (!(error instanceof OutboxTransactionNotFoundError)) throw error + if (!(error instanceof OutboxTransactionNotFoundError)) { + // Development-only oracle probe; a production build removes it. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.(`offline-transactions#phase-write-fault`, true) + throw error + } } } await this.outbox.remove(transaction.id) this.scheduler.markCompleted(transaction) } catch (error) { const storageError = toError(error) + // Development-only oracle probe; a production build removes it. The + // failed transaction is still pending, so any other pending one is a peer. + probing && + ( + globalThis as { + __oracleProbe?: (name: string, condition: unknown) => void + } + ).__oracleProbe?.( + `offline-transactions#fault-before-peer`, + this.scheduler.getPendingCount() > 1, + ) this.fatalError = storageError this.scheduler.markFailed(transaction) this.clearRetryTimer() diff --git a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts index 98004b3f28..7de80f732a 100644 --- a/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler-oracle.property.test.ts @@ -1,16 +1,18 @@ -import { fc } from '@fast-check/vitest' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { isDeepStrictEqual } from 'node:util' +import fc from 'fast-check' +import { vi } from 'vitest' import { KeyScheduler } from '../src/executor/KeyScheduler' -import { - oraclePropertyOptions, - oracleReplayTest, - oracleRuns, -} from '../../db/tests/oracle-config' +import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { declareOracle } from '../../db/tests/oracle-declaration' import { BASE_TIME, createTransaction } from './key-scheduler-support' +import type { Observation } from '../../db/tests/oracle-property' import type { OfflineTransaction } from '../src/types' -const FIFO_LEDGER_PROPERTY = `offline.key-scheduler-fifo-ledger` +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [`src/executor/KeyScheduler.ts`], +}) type Command = | { @@ -25,7 +27,9 @@ type Command = | { type: `complete` } | { type: `fail` } | { type: `retry`; delay: number; payload: number } - | { type: `bulkUpdate`; payload: number } + | { type: `retryRemoved`; delay: number; payload: number } + | { type: `release` } + | { type: `bulkUpdate`; payload: number; reset: boolean } | { type: `remove`; ids: Array } | { type: `advance`; duration: number } | { type: `clear` } @@ -63,9 +67,6 @@ type Snapshot = { runningCount: number } -const FIXED_SEED = 1815 -const RUNS = oracleRuns(150) - /** * # Which offline transaction may run next? * @@ -75,42 +76,94 @@ const RUNS = oracleRuns(150) * work. Failure updates retry data before releasing the active transaction, * without changing its place. Replay reconciliation may retire only unissued * IDs. Clear retires all scheduler work and leaves the scheduler reusable. + * Scheduling an ID that is already pending is a duplicate schedule: `schedule` + * returns false, which TransactionExecutor uses during outbox replay to tell + * newly loaded transactions from ones it already holds. The model also keeps + * the first record, its place, payload, and retry data; that part has no + * contract source yet and is stated as a limit. * * Model: a declarative ledger, fake clock, stable creation sequence, and active * ID predict scheduler observations. The model does not import scheduler state * or production classifiers. * - * History grammar: legal commands schedule, inspect, start, fulfill, reject, - * update one or all pending records, reconcile one replay snapshot, advance - * the clock, and clear. A reject is followed immediately by its retry update - * and active release, matching TransactionExecutor's update-then-release order. - * IDs are unique while pending. At most five IDs exist in generated histories. - * Slot choice prevents duplicate pending IDs; creation time controls FIFO order; - * delay and clock advance control readiness; payload makes updates observable. - * The selector reaches every legal command, including removal during a run. - * Histories use 1-50 tokens plus automatic retry steps. Slots span 0-4, - * creation times 0-3 seconds, delays and advances 0-5 seconds, and payloads - * -5 through 5. The domain includes equal creation times, zero and positive - * delays, and negative and positive payloads. - * The grammar excludes duplicate pending IDs, start without eligible work, - * completion without active work, and any intervening command before retry. + * History grammar: the commands are `schedule`, `getNext`, `start`, + * `complete`, `fail`, `release`, `bulkUpdate`, `remove`, `advance`, and + * `clear`, named for the scheduler calls TransactionExecutor makes. A `fail` + * is settled at once, as TransactionExecutor settles a failure: by `retry` + * (update the record, then release it), by `complete` (a terminal removal), or + * by `retryRemoved` (update, then remove because the outbox row is gone, then + * release). A `release` without an update is the executor's storage-failure + * path. A `bulkUpdate` changes every payload, and may reset every deadline to + * now, as `resetRetryDelays` does. A schedule takes a free slot `tx-0` to + * `tx-6`, schedules `missing`, the ID that replay snapshots name without the + * scheduler holding it, or duplicates a pending ID, as outbox replay does. + * At most seven IDs are pending; that is the grammar's bound, and production + * has none. Creation time controls FIFO order and never changes after + * scheduling; delay and clock advance control readiness; payload makes updates + * observable. Each token picks uniformly among the legal choices, and a + * removal choice folds four modes. Histories use 1-50 tokens plus the + * follow-up of each failure. Slots span 0-6, creation times 0-3 seconds, + * delays and advances 0-5 seconds, and payloads -5 through 5. The grammar + * excludes start without eligible work, completion without active work, and + * any command between a failure and its settlement. * * Production driver and refinement check: the driver calls the real - * executor-facing scheduler methods. After every command, it compares the next - * eligible ID and payload, ordered pending records, counts, and active state. + * executor-facing scheduler methods and records, after every command, the next + * eligible ID and payload, ordered pending records, counts, and active state, + * plus what `schedule` returned, what `start` was given, and what a removal + * returned. A retry's update is recorded before its release, so a scheduler + * that releases on update is visible. After each production call it records + * the command as reach, and it records situations the laws turn on: a + * duplicate rejected with six or more pending, a duplicate of the active ID, + * clear while active, a removal naming an unheld ID or the active ID with a + * waiting sibling, and a blocked head with a ready sibling. A `fail` makes no + * scheduler call and records no reach. The model computes the same records + * from the ledger alone; the check compares them per checkpoint, each field + * under its law: `dedupe` (whether a schedule was accepted), `fifo` (the next + * eligible ID), `update` (payloads), `ledger` (ordered pending records and + * counts), `serial` (the active count), and `retirement` (what a removal or + * clear retires). * * Reach and controls: one fixed history reaches every transition. Two fixed - * 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_ORACLE_SEED, - * TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY to replay a failure - * directly. + * histories cross retry-deadline order, two more fix an active removal with a + * waiting sibling and a blocked FIFO head at its retry deadline, one + * duplicates a pending ID with another creation time and payload, one + * schedules `missing` after a replay snapshot named it, one crosses a release, + * a terminal failure, and a removed retry, and one resets a delayed head's + * deadline. The declared state `duplicate schedule` is bound to the + * scheduler's `duplicate-schedule` probe: the model predicts, from the + * history alone, which histories duplicate a pending ID, the probe reports + * which ones production rejected, and the two must agree in every history + * (law `reach agreement`). A constructed history fills six slots and then + * duplicates one, so every campaign reaches `duplicate schedule with six or + * more pending`, which the driver records. Five calibrations prove the comparison rejects bypass, double + * issue, stale payload, stale clear, and failed selective retirement; three + * more reject a stale next payload, revoked work kept in the pending ledger, + * and a wrong count; one more rejects an accepted duplicate. The property is + * `offline.key-scheduler-fifo-ledger` on `oracleProperty`; a failure prints + * the seed, path, and property to set to replay it, and a path replay runs + * only the random campaign. * * Limits: persistence, caller promise settlement, leadership, and real timers * have separate owners. The model does not promise fairness beyond FIFO order. + * Creation times never change after scheduling (maintainer decision, + * 2026-11-04). The executor's promise that reloaded work can run at once + * belongs to the outbox replay owner; this oracle owns only that a deadline + * reset reaches every record. Keeping the first record on a duplicate schedule + * awaits a maintainer decision. */ +// Seven slots `tx-0` to `tx-6`, and slot 7 for `missing`, the ID that replay +// snapshots name without the scheduler holding it. Production has no pending +// bound; seven is the grammar's bound, so six or more pending is reachable. +const SLOTS = 7 +const MISSING_SLOT = SLOTS +const MAX_PENDING = SLOTS +const idOf = (slot: number) => + slot === MISSING_SLOT ? `missing` : `tx-${slot}` +const slotOf = (id: string) => + id === `missing` ? MISSING_SLOT : Number(id.slice(`tx-`.length)) + type CommandToken = { selector: number slot: number @@ -141,8 +194,10 @@ type PlanningState = { // scheduler has no corresponding retryable state. const commandToken = fc.record({ - selector: fc.nat(), - slot: fc.integer({ min: 0, max: 4 }), + // Unbiased: fast-check biases small naturals, which picked the first few + // choices almost always, so start, fail, and retry were rarely generated. + selector: fc.noBias(fc.nat()), + slot: fc.integer({ min: 0, max: SLOTS - 1 }), createdAt: fc.integer({ min: 0, max: 3 }), delay: fc.integer({ min: 0, max: 5 }), payload: fc.integer({ min: -5, max: 5 }), @@ -163,8 +218,8 @@ function planningNext(state: PlanningState): PlanningEntry | undefined { } function nextFreeSlot(state: PlanningState, preferred: number): number { - for (let offset = 0; offset < 5; offset++) { - const slot = (preferred + offset) % 5 + for (let offset = 0; offset < SLOTS; offset++) { + const slot = (preferred + offset) % SLOTS if (!state.pending.some(({ id }) => id === `tx-${slot}`)) return slot } throw new Error(`No transaction slot is available`) @@ -175,8 +230,9 @@ function applyPlanningCommand( nextCommand: Command, ): void { if (nextCommand.type === `schedule`) { + if (state.pending.some(({ id }) => id === idOf(nextCommand.slot))) return state.pending.push({ - id: `tx-${nextCommand.slot}`, + id: idOf(nextCommand.slot), createdAt: BASE_TIME + nextCommand.createdAt * 1000, nextAttemptAt: state.now + nextCommand.delay * 1000, sequence: state.nextSequence++, @@ -186,6 +242,16 @@ function applyPlanningCommand( } else if (nextCommand.type === `complete`) { state.pending = state.pending.filter(({ id }) => id !== state.activeId) state.activeId = undefined + state.retryableId = undefined + } else if (nextCommand.type === `release`) { + state.activeId = undefined + } else if (nextCommand.type === `retryRemoved`) { + state.pending = state.pending.filter(({ id }) => id !== state.retryableId) + state.activeId = undefined + state.retryableId = undefined + } else if (nextCommand.type === `bulkUpdate`) { + if (nextCommand.reset) + for (const entry of state.pending) entry.nextAttemptAt = state.now } else if (nextCommand.type === `fail`) { state.retryableId = state.activeId } else if (nextCommand.type === `retry`) { @@ -218,7 +284,13 @@ function buildLegalHistory(tokens: Array): Array { for (const token of tokens) { const choices: Array = [ { type: `getNext` }, - { type: `bulkUpdate`, payload: token.payload }, + // A reset is TransactionExecutor's resetRetryDelays: every pending + // deadline becomes now, in one batch. + { + type: `bulkUpdate`, + payload: token.payload, + reset: token.duration % 2 === 1, + }, { type: `advance`, duration: token.duration }, { type: `clear` }, ] @@ -239,7 +311,18 @@ function buildLegalHistory(tokens: Array): Array { }) } - if (state.pending.length < 5) { + if ( + state.pending.length < MAX_PENDING && + !state.pending.some(({ id }) => id === `missing`) + ) + choices.push({ + type: `schedule`, + slot: MISSING_SLOT, + createdAt: token.createdAt, + delay: token.delay, + payload: token.payload, + }) + if (state.pending.length < MAX_PENDING) { choices.push({ type: `schedule`, slot: nextFreeSlot(state, token.slot), @@ -248,38 +331,140 @@ function buildLegalHistory(tokens: Array): Array { payload: token.payload, }) } + if (state.pending.length > 0) { + // Outbox replay schedules transactions the scheduler may already hold. + const duplicated = state.pending[token.slot % state.pending.length]! + choices.push({ + type: `schedule`, + slot: slotOf(duplicated.id), + createdAt: token.createdAt, + delay: token.delay, + payload: token.payload, + }) + } if (state.activeId) { + // A replay snapshot may name the active ID, a waiting sibling, and an + // ID the scheduler does not hold. choices.push({ type: `remove`, - ids: [state.activeId, ...removable.slice(0, 1).map(({ id }) => id)], + ids: [ + state.activeId, + ...removable.slice(0, 1).map(({ id }) => id), + ...(Math.abs(token.payload) % 2 === 1 ? [`missing`] : []), + ], }) } if (planningNext(state)) choices.push({ type: `start` }) if (state.activeId) { - choices.push({ type: `complete` }, { type: `fail` }) + // A release without an update is the executor's storage-failure path. + choices.push({ type: `complete` }, { type: `fail` }, { type: `release` }) } const nextCommand = choices[token.selector % choices.length]! history.push(nextCommand) applyPlanningCommand(state, nextCommand) + // TransactionExecutor follows a failure with its retry update, a + // terminal removal, or a retry update whose transaction is then removed. if (nextCommand.type === `fail`) { - const retry: Command = { - type: `retry`, - delay: token.delay, - payload: token.payload, - } - history.push(retry) - applyPlanningCommand(state, retry) + const mode = Math.abs(token.payload) % 3 + const follow: Command = + mode === 0 + ? { type: `retry`, delay: token.delay, payload: token.payload } + : mode === 1 + ? { type: `complete` } + : { + type: `retryRemoved`, + delay: token.delay, + payload: token.payload, + } + history.push(follow) + applyPlanningCommand(state, follow) } } return history } -const legalHistory = fc - .array(commandToken, { minLength: 1, maxLength: 50 }) - .map(buildLegalHistory) +// The tokens a command list came from, found by search and accepted only if +// they rebuild that exact list. A fixed command list is then a member of the +// generated language, and fast-check can shrink it. +function tokensOf(history: ReadonlyArray): Array { + const tokens: Array = [] + for (let index = 0; index < history.length; index++) { + const command = history[index]! + const prefix = buildLegalHistory(tokens) + const follow = command.type === `fail` ? history[index + 1] : undefined + const retry = + follow?.type === `retry` || follow?.type === `retryRemoved` + ? follow + : undefined + const base: CommandToken = { + selector: 0, + slot: command.type === `schedule` ? command.slot : 0, + createdAt: command.type === `schedule` ? command.createdAt : 0, + delay: + command.type === `schedule` ? command.delay : retry ? retry.delay : 0, + payload: + command.type === `schedule` || command.type === `bulkUpdate` + ? command.payload + : retry + ? retry.payload + : 0, + duration: + command.type === `advance` + ? command.duration + : command.type === `bulkUpdate` && command.reset + ? 1 + : 0, + } + // A removal's IDs come from the token's payload and slot. + // A duplicate schedule takes its ID from the token's slot among the pending. + const candidates = + command.type === `remove` + ? Array.from({ length: 11 * SLOTS }, (_, n) => ({ + ...base, + payload: (n % 11) - 5, + slot: Math.floor(n / 11), + })) + : command.type === `schedule` + ? Array.from({ length: SLOTS }, (_, slot) => ({ ...base, slot })) + : command.type === `fail` && !retry + ? // A terminal removal's payload only selects the follow-up. + Array.from({ length: 11 }, (_, n) => ({ + ...base, + payload: n - 5, + })) + : [base] + // A failure is matched with its follow-up, which the same token decides. + const wanted = follow ? [command, follow] : [command] + let found: CommandToken | undefined + for (const candidate of candidates) + for (let selector = 0; selector < 16 && !found; selector++) { + const token = { ...candidate, selector } + const built = buildLegalHistory([...tokens, token]) + if ( + isDeepStrictEqual( + built.slice(prefix.length, prefix.length + wanted.length), + wanted, + ) + ) + found = token + } + if (!found) throw new Error(`command ${index} is not legal here`) + tokens.push(found) + if (follow) index++ + } + if (!isDeepStrictEqual(buildLegalHistory(tokens), history)) + throw new Error(`not a legal scheduler history`) + return tokens +} + +const legalHistory: fc.Arbitrary> = fc + // fast-check's default size keeps arrays near ten elements whatever + // maxLength says; `medium` draws lengths across the declared 1 to 50. + .array(commandToken, { minLength: 1, maxLength: 50, size: `medium` }) + .map(buildLegalHistory, (value) => tokensOf(value as Array)) function cloneTransaction(transaction: OfflineTransaction): OfflineTransaction { return { @@ -308,29 +493,6 @@ function expectedNext(model: Model): OfflineTransaction | undefined { return first && first.nextAttemptAt <= model.now ? first : undefined } -function snapshot( - scheduler: KeyScheduler, - observedNext: OfflineTransaction | undefined, -): Snapshot { - return { - next: observedNext - ? { - id: observedNext.id, - payload: observedNext.metadata?.payload, - } - : undefined, - pending: scheduler.getAllPendingTransactions().map((transaction) => ({ - id: transaction.id, - createdAt: transaction.createdAt.getTime(), - nextAttemptAt: transaction.nextAttemptAt, - retryCount: transaction.retryCount, - payload: transaction.metadata?.payload, - })), - pendingCount: scheduler.getPendingCount(), - runningCount: scheduler.getRunningCount(), - } -} - function expectedSnapshot(model: Model): Snapshot { const next = expectedNext(model) return { @@ -352,456 +514,759 @@ function expectedSnapshot(model: Model): Snapshot { } } -function assertSnapshot( - history: ReadonlyArray, - commandIndex: number, - actual: Snapshot, - expected: Snapshot, -): void { - expect(actual, JSON.stringify({ history, commandIndex })).toEqual(expected) -} +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- +// +// The ledger replays the commands and owes, after each one, the scheduler's +// snapshot, the transaction a `start` is given, and the IDs a removal +// retires (requested IDs other than the active one, once each, including IDs +// the scheduler does not hold). A +// command the lifecycle does not allow is a setup error with its name: the +// grammar never generates one, so a fixed history or a grammar change that does is +// caught here. -type ObservationMutant = { - commandIndex: number - apply: (snapshot: Snapshot) => Snapshot +type Owed = { + snapshot: Snapshot + accepted?: boolean + started?: { id: string; payload: unknown } + removed?: Array } +const checkpointOf = (index: number, command: Command) => + `${index}: ${command.type}` -function runHistory( - history: Array, - observationMutant?: ObservationMutant, -): Set { - vi.useFakeTimers() - vi.setSystemTime(BASE_TIME) - const scheduler = new KeyScheduler() - const model: Model = { - now: BASE_TIME, - nextSequence: 0, - pending: [], - } - const coverage = new Set() - - history.forEach((nextCommand, commandIndex) => { - // TransactionExecutor updates a failed transaction before asking for more - // work. Other commands in this short state are not legal lifecycle calls. - if (model.retryableId && nextCommand.type !== `retry`) { - throw new Error(`retry must immediately follow fail`) +function ledgerModel(history: ReadonlyArray): Map { + const model: Model = { now: BASE_TIME, nextSequence: 0, pending: [] } + const owed = new Map() + for (const [index, command] of history.entries()) { + const extra: Partial = {} + const illegal = (reason: string) => { + throw new Error(`command ${index} (${command.type}): ${reason}`) } - - if (nextCommand.type === `schedule`) { - const id = `tx-${nextCommand.slot}` - if (model.pending.some(({ transaction }) => transaction.id === id)) { - throw new Error(`cannot schedule duplicate transaction ${id}`) - } - const transaction = createTransaction( - id, - BASE_TIME + nextCommand.createdAt * 1000, - model.now + nextCommand.delay * 1000, - nextCommand.payload, + // TransactionExecutor settles a failed transaction before asking for + // more work: a retry update, a terminal removal, or a retry update whose + // transaction is then removed comes straight after the failure. + if ( + model.retryableId && + command.type !== `retry` && + command.type !== `complete` && + command.type !== `retryRemoved` + ) + illegal(`a failure must be settled before anything else`) + if (command.type === `schedule`) { + // Model law: a duplicate schedule of a pending ID is rejected, so the first record keeps + // its place, creation time, and payload. + extra.accepted = !model.pending.some( + ({ transaction }) => transaction.id === idOf(command.slot), ) - scheduler.schedule(transaction) - model.pending.push({ - transaction: cloneTransaction(transaction), - sequence: model.nextSequence++, - }) - } else if (nextCommand.type === `getNext`) { - scheduler.getNext() - } else if (nextCommand.type === `start`) { - const transaction = expectedNext(model) - if (!transaction) throw new Error(`cannot start without eligible work`) - const observedNext = scheduler.getNext() - expect({ - id: observedNext?.id, - payload: observedNext?.metadata?.payload, - }).toEqual({ + if (extra.accepted) + model.pending.push({ + transaction: createTransaction( + idOf(command.slot), + BASE_TIME + command.createdAt * 1000, + model.now + command.delay * 1000, + command.payload, + ), + sequence: model.nextSequence++, + }) + } else if (command.type === `start`) { + const transaction = expectedNext(model) ?? illegal(`no eligible work`) + extra.started = { id: transaction.id, payload: transaction.metadata?.payload, - }) - scheduler.markStarted(observedNext!) + } model.activeId = transaction.id - } else if (nextCommand.type === `complete`) { - if (!model.activeId) - throw new Error(`cannot complete without active work`) - const index = model.pending.findIndex( - ({ transaction }) => transaction.id === model.activeId, + } else if (command.type === `complete`) { + if (!model.activeId) illegal(`no active work`) + model.pending = model.pending.filter( + ({ transaction }) => transaction.id !== model.activeId, ) - const transaction = model.pending[index]!.transaction - scheduler.markCompleted(transaction) - model.pending.splice(index, 1) model.activeId = undefined - } else if (nextCommand.type === `fail`) { - if (!model.activeId) throw new Error(`cannot fail without active work`) - const transaction = model.pending.find( - ({ transaction: candidate }) => candidate.id === model.activeId, - )!.transaction - model.retryableId = transaction.id - } else if (nextCommand.type === `retry`) { - if (!model.retryableId) throw new Error(`cannot retry before a failure`) + model.retryableId = undefined + } else if (command.type === `release`) { + if (!model.activeId) illegal(`no active work`) + model.activeId = undefined + } else if (command.type === `fail`) { + if (!model.activeId) illegal(`no active work`) + model.retryableId = model.activeId + } else if (command.type === `retry` || command.type === `retryRemoved`) { + if (!model.retryableId) illegal(`no failure to retry`) const entry = model.pending.find( ({ transaction }) => transaction.id === model.retryableId, )! - const failedTransaction = entry.transaction - const updated = { - ...failedTransaction, - retryCount: failedTransaction.retryCount + 1, - nextAttemptAt: model.now + nextCommand.delay * 1000, - metadata: { payload: nextCommand.payload }, + entry.transaction = { + ...entry.transaction, + retryCount: entry.transaction.retryCount + 1, + nextAttemptAt: model.now + command.delay * 1000, + metadata: { payload: command.payload }, } - scheduler.updateTransaction(cloneTransaction(updated)) - scheduler.markFailed(failedTransaction) - entry.transaction = updated + // Between the update and the release, the transaction is still active. + owed.set(`${index}: ${command.type} update`, { + snapshot: expectedSnapshot(model), + }) + if (command.type === `retryRemoved`) + model.pending = model.pending.filter( + ({ transaction }) => transaction.id !== model.retryableId, + ) model.activeId = undefined model.retryableId = undefined - } else if (nextCommand.type === `bulkUpdate`) { - const updated = model.pending.map(({ transaction }) => ({ - ...transaction, - metadata: { payload: nextCommand.payload }, - })) - scheduler.updateTransactions(updated.map(cloneTransaction)) - for (const [index, transaction] of updated.entries()) { - model.pending[index]!.transaction = transaction - } - } else if (nextCommand.type === `advance`) { - const duration = nextCommand.duration * 1000 - vi.advanceTimersByTime(duration) - model.now += duration - } else if (nextCommand.type === `remove`) { - const expectedRemoved = [ - ...new Set(nextCommand.ids.filter((id) => id !== model.activeId)), + } else if (command.type === `bulkUpdate`) { + for (const entry of model.pending) + entry.transaction = { + ...entry.transaction, + ...(command.reset ? { nextAttemptAt: model.now } : {}), + metadata: { payload: command.payload }, + } + } else if (command.type === `advance`) { + model.now += command.duration * 1000 + } else if (command.type === `remove`) { + extra.removed = [ + ...new Set(command.ids.filter((id) => id !== model.activeId)), ] - expect(scheduler.removePendingTransactions(nextCommand.ids)).toEqual( - expectedRemoved, - ) - const ids = new Set(nextCommand.ids) + const ids = new Set(command.ids) model.pending = model.pending.filter( ({ transaction }) => transaction.id === model.activeId || !ids.has(transaction.id), ) - } else { - scheduler.clear() + } else if (command.type === `clear`) { model.pending = [] model.activeId = undefined model.retryableId = undefined } - - coverage.add(nextCommand.type) - const observed = snapshot(scheduler, scheduler.getNext()) - const actual = - observationMutant?.commandIndex === commandIndex - ? observationMutant.apply(observed) - : observed - assertSnapshot(history, commandIndex, actual, expectedSnapshot(model)) - }) - - return coverage + owed.set(checkpointOf(index, command), { + snapshot: expectedSnapshot(model), + ...extra, + }) + } + return owed } -declareOracle({ check: [runHistory], model: [expectedSnapshot] }) - -describe(`KeyScheduler generated lifecycle`, () => { - afterEach(() => vi.useRealTimers()) - - // 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 }, - ) - }, - ) +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- +// +// The driver calls the scheduler as TransactionExecutor does, under a fake +// clock. It starts whatever `getNext` returns, fails the transaction it +// started, and builds the retry update from it; it never reads the model. - 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), - ) - }, - ) +function snapshotOf(scheduler: KeyScheduler): Snapshot { + const next = scheduler.getNext() + return { + next: next ? { id: next.id, payload: next.metadata?.payload } : undefined, + pending: scheduler.getAllPendingTransactions().map((transaction) => ({ + id: transaction.id, + createdAt: transaction.createdAt.getTime(), + nextAttemptAt: transaction.nextAttemptAt, + retryCount: transaction.retryCount, + payload: transaction.metadata?.payload, + })), + pendingCount: scheduler.getPendingCount(), + runningCount: scheduler.getRunningCount(), + } +} - it(`reconstructs active removal with a waiting sibling`, () => { - const token = (selector: number, slot = 0): CommandToken => ({ - selector, - slot, - createdAt: 0, - delay: 0, - payload: 1, - duration: 0, - }) - const history = buildLegalHistory([ - token(4), // schedule tx-0 - token(5, 1), // schedule tx-1 - token(6), // start tx-0 - token(6), // request removal of both IDs while tx-0 is active - token(6), // complete tx-0 - ]) - - expect(history).toEqual([ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - { type: `remove`, ids: [`tx-0`, `tx-1`] }, - { type: `complete` }, - ]) - runHistory(history) - - const duplicateSlotRequest = buildLegalHistory([token(4), token(5)]) - expect(duplicateSlotRequest[1]).toEqual({ - type: `schedule`, - slot: 1, - createdAt: 0, - delay: 0, - payload: 1, - }) - }) - - it(`reconstructs a blocked FIFO head at the retry deadline`, () => { - const token = ( - selector: number, - slot: number, - delay = 0, - duration = 0, - ): CommandToken => ({ - selector, - slot, - createdAt: slot, - delay, - payload: 1, - duration, - }) - const history = buildLegalHistory([ - token(4, 0), // schedule tx-0 - token(5, 1), // schedule tx-1 - token(6, 0), // start tx-0 - token(8, 0, 5), // fail tx-0, then retry after five seconds - token(0, 0), // tx-1 is ready but cannot bypass tx-0 - token(2, 0, 0, 4), - token(0, 0), // still before the retry deadline - token(2, 0, 0, 1), - token(6, 0), // tx-0 becomes eligible at the deadline - ]) - - expect(history.map(({ type }) => type)).toEqual([ - `schedule`, - `schedule`, - `start`, - `fail`, - `retry`, - `getNext`, - `advance`, - `getNext`, - `advance`, - `start`, - ]) - expect(history[4]).toEqual({ type: `retry`, delay: 5, payload: 1 }) - runHistory(history) - }) - - it(`replays a generated wrong count at the same checkpoint`, () => { - const wrongCount = fc.property(legalHistory, (history) => { - runHistory(history, { - commandIndex: history.length - 1, - apply: (actual) => ({ - ...actual, - pendingCount: actual.pendingCount + 1, - }), +function driveScheduler( + history: ReadonlyArray, + observe: (checkpoint: string, value: unknown) => void, +) { + vi.useFakeTimers() + vi.setSystemTime(BASE_TIME) + try { + const scheduler = new KeyScheduler() + let active: OfflineTransaction | undefined + for (const [index, command] of history.entries()) { + const extra: Partial = {} + // Reach records what production did, after it did it. + const reached = (state: string) => observe(`reach`, state) + if (command.type === `schedule`) { + const crowded = scheduler.getPendingCount() >= 6 + const id = idOf(command.slot) + extra.accepted = scheduler.schedule( + createTransaction( + id, + BASE_TIME + command.createdAt * 1000, + Date.now() + command.delay * 1000, + command.payload, + ), + ) + reached(`schedule`) + if (!extra.accepted && crowded) reached(duplicateWhenCrowded) + if (!extra.accepted && active?.id === id) + reached(`duplicate of the active ID`) + } else if (command.type === `getNext`) { + const next = scheduler.getNext() + reached(`getNext`) + const [head, sibling] = scheduler.getAllPendingTransactions() + if ( + !next && + scheduler.getRunningCount() === 0 && + head && + sibling && + head.nextAttemptAt > Date.now() && + sibling.nextAttemptAt <= Date.now() + ) + reached(`blocked head with a ready sibling`) + } else if (command.type === `start`) { + const next = scheduler.getNext() + extra.started = next + ? { id: next.id, payload: next.metadata?.payload } + : undefined + if (next) { + scheduler.markStarted(next) + reached(`start`) + } + active = next && cloneTransaction(next) + } else if (command.type === `complete`) { + if (active) { + scheduler.markCompleted(active) + reached(`complete`) + } + active = undefined + } else if (command.type === `release`) { + if (active) { + scheduler.markFailed(active) + reached(`release`) + } + active = undefined + } else if (command.type === `retry` || command.type === `retryRemoved`) { + if (active) { + scheduler.updateTransaction({ + ...cloneTransaction(active), + retryCount: active.retryCount + 1, + nextAttemptAt: Date.now() + command.delay * 1000, + metadata: { payload: command.payload }, + }) + observe(`${index}: ${command.type} update`, { + snapshot: snapshotOf(scheduler), + }) + // TransactionExecutor removes a transaction whose outbox row is + // gone, then releases it in its finally block. + if (command.type === `retryRemoved`) scheduler.markCompleted(active) + scheduler.markFailed(active) + reached(command.type) + } + active = undefined + } else if (command.type === `bulkUpdate`) { + scheduler.updateTransactions( + scheduler.getAllPendingTransactions().map((transaction) => ({ + ...cloneTransaction(transaction), + ...(command.reset ? { nextAttemptAt: Date.now() } : {}), + metadata: { payload: command.payload }, + })), + ) + reached(`bulkUpdate`) + } else if (command.type === `advance`) { + vi.advanceTimersByTime(command.duration * 1000) + reached(`advance`) + } else if (command.type === `remove`) { + const held = new Set( + scheduler.getAllPendingTransactions().map(({ id }) => id), + ) + if (command.ids.some((id) => !held.has(id))) + reached(`removal names an unheld ID`) + if ( + active && + command.ids.includes(active.id) && + command.ids.some((id) => id !== active!.id && held.has(id)) + ) + reached(`removal names the active ID and a waiting sibling`) + extra.removed = scheduler.removePendingTransactions(command.ids) + reached(`remove`) + } else if (command.type === `clear`) { + if (scheduler.getRunningCount() === 1) reached(`clear while active`) + scheduler.clear() + reached(`clear`) + active = undefined + } + observe(checkpointOf(index, command), { + snapshot: snapshotOf(scheduler), + ...extra, }) - }) - const first = fc.check(wrongCount, { seed: FIXED_SEED, numRuns: RUNS }) - expect(first.failed).toBe(true) - expect(first.error).toContain(`AssertionError:`) - expect(first.error).toContain(`"commandIndex":`) - const replayPath = first.counterexamplePath - if (replayPath === null) throw new Error(`Missing shrink path`) - - const replay = fc.check(wrongCount, { - seed: first.seed, - path: replayPath, - numRuns: RUNS, - }) - expect(replay.failed).toBe(true) - expect(replay.counterexample).toEqual(first.counterexample) - expect(replay.error?.split(`\n`)[0]).toEqual(first.error.split(`\n`)[0]) - }) - - it(`executes every modeled transition in a fixed replay`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 0, delay: 0, payload: 2 }, - { type: `getNext` }, - { type: `start` }, - { type: `remove`, ids: [`tx-0`, `tx-1`, `missing`] }, - { type: `fail` }, - { type: `retry`, delay: 2, payload: 3 }, - { type: `getNext` }, - { type: `bulkUpdate`, payload: 4 }, - { type: `advance`, duration: 2 }, - { type: `start` }, - { type: `complete` }, - { type: `clear` }, - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 5 }, - ] - expect(runHistory(history)).toEqual( - new Set([ - `schedule`, - `getNext`, - `start`, - `fail`, - `retry`, - `bulkUpdate`, - `remove`, - `advance`, - `complete`, - `clear`, - ]), - ) - }) - - it.each([ - [ - `an earlier retry deadline than its sibling`, - [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 5, payload: 2 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 2, payload: 3 }, - { type: `advance`, duration: 2 }, - { type: `start` }, - ], - ], - [ - `a later retry deadline than its sibling`, - [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 1, payload: 2 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 2, payload: 3 }, - { type: `advance`, duration: 2 }, - { type: `start` }, - ], - ], - ] satisfies Array<[string, Array]>)( - `covers %s`, - (_name, history) => { - runHistory(history) - }, - ) - - it(`rejects bypassing the delayed FIFO head on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 0, payload: 2 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 5, payload: 3 }, - ] - - expect(() => - runHistory(history, { - commandIndex: 4, - apply: (actual) => ({ - ...actual, - next: { id: `tx-1`, payload: 2 }, - }), - }), - ).toThrow() - }) + } + } finally { + vi.useRealTimers() + } +} - it(`rejects permitting two active transactions on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - ] +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- - expect(() => - runHistory(history, { - commandIndex: 1, - apply: (actual) => ({ ...actual, runningCount: 2 }), - }), - ).toThrow() - }) - - it(`rejects losing an updated payload on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - { type: `fail` }, - { type: `retry`, delay: 0, payload: 9 }, - ] +function checkLedger( + _history: ReadonlyArray, + trace: ReadonlyArray, + owed: ReturnType, +) { + for (const [checkpoint, expected] of owed) { + const seen = trace.find((entry) => entry.checkpoint === checkpoint) + ?.value as Owed | undefined + if (!seen) + return differs( + checkpoint, + `trace`, + undefined, + expected, + `the driver recorded no "${checkpoint}"`, + )! + const wrong = + differs( + checkpoint, + `dedupe`, + seen.accepted, + expected.accepted, + `schedule reported another acceptance`, + ) ?? + differs( + checkpoint, + `fifo`, + seen.started?.id, + expected.started?.id, + `start was given another transaction`, + ) ?? + differs( + checkpoint, + `fifo`, + seen.snapshot.next?.id, + expected.snapshot.next?.id, + `another transaction is next`, + ) ?? + differs( + checkpoint, + `update`, + seen.started?.payload, + expected.started?.payload, + `start was given a stale payload`, + ) ?? + differs( + checkpoint, + `update`, + seen.snapshot.next?.payload, + expected.snapshot.next?.payload, + `the next transaction has a stale payload`, + ) ?? + differs( + checkpoint, + `retirement`, + seen.removed, + expected.removed, + `a removal retired other IDs`, + ) ?? + differs( + checkpoint, + `serial`, + seen.snapshot.runningCount, + expected.snapshot.runningCount, + `the active count differs`, + ) ?? + differs( + checkpoint, + `retirement`, + seen.snapshot.pending.map(({ id }) => id).sort(), + expected.snapshot.pending.map(({ id }) => id).sort(), + `other transactions are pending`, + ) ?? + differs( + checkpoint, + `ledger`, + seen.snapshot, + expected.snapshot, + `the pending ledger differs`, + ) + if (wrong) return wrong + } + return pass +} - expect(() => - runHistory(history, { - commandIndex: 3, - apply: (actual) => ({ - ...actual, - next: actual.next ? { ...actual.next, payload: 1 } : undefined, - }), - }), - ).toThrow() - }) +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- - it(`rejects retaining stale work after clear on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `start` }, - { type: `clear` }, - ] +const commandTypes = [ + `schedule`, + `getNext`, + `start`, + `fail`, + `retry`, + `retryRemoved`, + `release`, + `bulkUpdate`, + `remove`, + `advance`, + `complete`, + `clear`, +] as const +// Situations the laws turn on, recorded by the driver from production. +const observedStates = [ + `clear while active`, + `duplicate of the active ID`, + `removal names an unheld ID`, + `removal names the active ID and a waiting sibling`, + `blocked head with a ready sibling`, +] as const +// The driver records no reach for a failure: the scheduler sees a failure +// only through the update and release that settle it. +const executedCommands = commandTypes.filter((type) => type !== `fail`) +// A history enters this state when it duplicates a pending ID. Production +// marks it with a probe, so its reach is an agreement between model and +// production. +const duplicateSchedule = `duplicate schedule` +// The driver records a duplicate that production rejected with six or more +// transactions pending; the grammar bound is seven, and production has none. +const duplicateWhenCrowded = `duplicate schedule with six or more pending` +const schedule = ( + slot: number, + createdAt: number, + delay: number, + payload: number, +): Command => ({ type: `schedule`, slot, createdAt, delay, payload }) - expect(() => - runHistory(history, { - commandIndex: 2, - apply: (actual) => ({ - ...actual, - pending: [ - { - id: `tx-0`, - createdAt: BASE_TIME, - nextAttemptAt: BASE_TIME, - retryCount: 0, - payload: 1, - }, - ], - pendingCount: 1, - }), - }), - ).toThrow() - }) - - it(`rejects retaining selectively revoked work on its scheduler path`, () => { - const history: Array = [ - { type: `schedule`, slot: 0, createdAt: 0, delay: 0, payload: 1 }, - { type: `schedule`, slot: 1, createdAt: 1, delay: 0, payload: 2 }, - { type: `remove`, ids: [`tx-0`] }, - ] +// Corrupts the first checkpoint whose recorded value `applies` to, or none. +const corruptFirst = + (applies: (value: Owed, checkpoint: string) => Owed | undefined) => + (trace: ReadonlyArray) => { + // Reach records are not ledger records, so no corruption applies to them. + const at = trace.findIndex( + ({ checkpoint, value }) => + checkpoint !== `reach` && + applies(value as Owed, checkpoint) !== undefined, + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { ...entry, value: applies(entry.value as Owed, entry.checkpoint) } + : entry, + ) + } - expect(() => - runHistory(history, { - commandIndex: 2, - apply: (actual) => ({ - ...actual, - pending: [ - { - id: `tx-0`, - createdAt: BASE_TIME, - nextAttemptAt: BASE_TIME, - retryCount: 0, - payload: 1, +oracleProperty({ + id: `offline.key-scheduler-fifo-ledger`, + law: { + statement: `KeyScheduler is one globally serial FIFO queue: equal creation times keep scheduling order, a delayed head blocks younger work, a failure updates retry data before releasing the active transaction without moving it, replay reconciliation retires only unissued IDs, clear retires all work and leaves the scheduler reusable, a removal returns the requested IDs other than the active one, which TransactionExecutor rejects, and a duplicate schedule of a pending ID returns false.`, + authority: `the established KeyScheduler FIFO tests and TransactionExecutor's calling order (packages/offline-transactions/src/executor); for duplicate schedules, TransactionExecutor's outbox replay, which uses schedule's result to find newly loaded transactions`, + limits: `Persistence, caller promise settlement, leadership, and real timers have separate owners; no fairness beyond FIFO order; at most seven pending IDs, a grammar bound (production has none); creation times never change after scheduling (maintainer decision, 2026-11-04), so no update changes one; the executor's promise that reloaded work can run at once belongs to the outbox replay owner; keeping the first record on a duplicate schedule awaits a maintainer decision.`, + owner: `Offline execution`, + }, + histories: { + arbitrary: legalHistory, + states: [ + { + name: duplicateSchedule, + probe: `offline-transactions#duplicate-schedule`, + }, + duplicateWhenCrowded, + ], + measured: [...commandTypes, ...observedStates], + constructed: [ + { + name: `six pending, then a duplicate`, + enters: [duplicateSchedule, duplicateWhenCrowded], + // Six schedules and a seventh that duplicates tx-0; the generated + // tokens then continue from that state, cut so the whole history + // stays within the grammar's 50 tokens. + build: (generated: Array) => { + const prefix = tokensOf([ + schedule(0, 0, 0, 1), + schedule(1, 1, 0, 1), + schedule(2, 2, 0, 1), + schedule(3, 3, 0, 1), + schedule(4, 0, 0, 1), + schedule(5, 1, 0, 1), + schedule(0, 2, 0, 2), + ]) + return buildLegalHistory([ + ...prefix, + ...tokensOf(generated).slice(0, 50 - prefix.length), + ]) + }, + }, + ], + runs: 150, + fixedSeeds: [1815], + fixed: [ + { + name: `every executed command`, + enters: executedCommands.filter( + (type) => type !== `retryRemoved` && type !== `release`, + ), + history: [ + schedule(0, 0, 0, 1), + schedule(1, 0, 0, 2), + { type: `getNext` }, + { type: `start` }, + { type: `remove`, ids: [`tx-0`, `tx-1`, `missing`] }, + { type: `fail` }, + { type: `retry`, delay: 2, payload: 3 }, + { type: `getNext` }, + { type: `bulkUpdate`, payload: 4, reset: false }, + { type: `advance`, duration: 2 }, + { type: `start` }, + { type: `complete` }, + { type: `clear` }, + schedule(0, 0, 0, 5), + ], + }, + { + name: `an active removal with a waiting sibling`, + enters: [`remove`, `complete`], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 0, 0, 1), + { type: `start` }, + // Request removal of both IDs while tx-0 is active: only tx-1 retires. + { type: `remove`, ids: [`tx-0`, `tx-1`] }, + { type: `complete` }, + ], + }, + { + name: `a blocked FIFO head at its retry deadline`, + enters: [`retry`, `advance`], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 1, 0, 1), + { type: `start` }, + { type: `fail` }, + { type: `retry`, delay: 5, payload: 3 }, + // tx-1 is ready but cannot bypass tx-0. + { type: `getNext` }, + { type: `advance`, duration: 4 }, + // Still before the retry deadline. + { type: `getNext` }, + { type: `advance`, duration: 1 }, + // tx-0 becomes eligible at the deadline. + { type: `start` }, + ], + }, + { + name: `a duplicate schedule with another creation time and payload`, + enters: [`schedule`, duplicateSchedule, `start`], + history: [ + schedule(0, 2, 0, 1), + schedule(1, 1, 0, 2), + // tx-0 again, now older and with another payload: it is ignored, so + // tx-1 stays first and tx-0 keeps payload 1. + schedule(0, 0, 0, 5), + { type: `start` }, + ], + }, + { + name: `a release, a terminal failure, and a removed retry`, + enters: [`release`, `complete`, `retryRemoved`], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 1, 0, 1), + { type: `start` }, + // A storage failure releases tx-0 without an update. + { type: `release` }, + { type: `start` }, + // A terminal failure removes tx-0. + { type: `fail` }, + { type: `complete` }, + { type: `start` }, + // tx-1's retry update lands, then its outbox row is gone. + { type: `fail` }, + { type: `retryRemoved`, delay: 1, payload: 2 }, + ], + }, + { + name: `a bulk deadline reset makes a delayed head eligible`, + enters: [`bulkUpdate`, `start`], + history: [ + schedule(0, 0, 3, 1), + { type: `getNext` }, + { type: `bulkUpdate`, payload: 1, reset: true }, + { type: `start` }, + ], + }, + { + name: `missing scheduled after a replay snapshot named it`, + enters: [`remove`, `schedule`], + history: [ + schedule(0, 0, 0, 1), + // The snapshot names tx-0 and missing; the scheduler holds only tx-0. + { type: `remove`, ids: [`tx-0`, `missing`] }, + schedule(MISSING_SLOT, 0, 0, 2), + { type: `start` }, + ], + }, + ...( + [ + [`an earlier retry deadline than its sibling`, 5], + [`a later retry deadline than its sibling`, 1], + ] as const + ).map(([name, siblingDelay]) => ({ + name, + enters: [`retry` as const], + history: [ + schedule(0, 0, 0, 1), + schedule(1, 1, siblingDelay, 2), + { type: `start` }, + { type: `fail` }, + { type: `retry`, delay: 2, payload: 3 }, + { type: `advance`, duration: 2 }, + { type: `start` }, + ] satisfies Array, + })), + ], + }, + model: ledgerModel, + // The scheduler marks a duplicate schedule with a probe; the model predicts + // it from its own ledger, so the two must agree in every history. + predict: (history) => ({ + [duplicateSchedule]: [...ledgerModel(history).values()].some( + ({ accepted }) => accepted === false, + ), + }), + driver: driveScheduler, + check: checkLedger, + calibrations: [ + { + name: `a ready sibling bypasses the delayed FIFO head`, + law: `fifo`, + corrupt: corruptFirst((value) => + value.snapshot.next === undefined && + value.snapshot.runningCount === 0 && + value.snapshot.pending.length >= 2 + ? { + ...value, + snapshot: { + ...value.snapshot, + next: { + id: value.snapshot.pending[1]!.id, + payload: value.snapshot.pending[1]!.payload, + }, + }, + } + : undefined, + ), + }, + { + name: `two transactions are active`, + law: `serial`, + corrupt: corruptFirst((value) => + value.snapshot.runningCount === 1 + ? { ...value, snapshot: { ...value.snapshot, runningCount: 2 } } + : undefined, + ), + }, + { + name: `a retried transaction starts with its old payload`, + law: `update`, + corrupt: (trace) => { + const retried = trace.findIndex(({ checkpoint }) => + checkpoint.endsWith(`retry`), + ) + const at = trace.findIndex( + ({ checkpoint, value }, index) => + index > retried && + retried >= 0 && + checkpoint.endsWith(`start`) && + (value as Owed).started !== undefined, + ) + if (at < 0) return undefined + return trace.map((entry, index) => { + if (index !== at) return entry + const value = entry.value as Owed + return { + ...entry, + value: { + ...value, + started: { ...value.started!, payload: `stale` }, }, - ...actual.pending, - ], - pendingCount: actual.pendingCount + 1, - }), + } + }) + }, + }, + { + name: `clear keeps stale work`, + law: `retirement`, + corrupt: corruptFirst((value, checkpoint) => + checkpoint.endsWith(`clear`) + ? { + ...value, + snapshot: { + ...value.snapshot, + pending: [ + { + id: `stale`, + createdAt: BASE_TIME, + nextAttemptAt: BASE_TIME, + retryCount: 0, + payload: 1, + }, + ], + pendingCount: 1, + }, + } + : undefined, + ), + }, + { + name: `the next transaction keeps a stale payload`, + law: `update`, + corrupt: corruptFirst((value) => + value.snapshot.next + ? { + ...value, + snapshot: { + ...value.snapshot, + next: { ...value.snapshot.next, payload: `stale` }, + }, + } + : undefined, + ), + }, + { + name: `revoked work stays in the pending ledger`, + law: `retirement`, + corrupt: corruptFirst((value) => { + const revoked = value.removed?.[0] + return revoked + ? { + ...value, + snapshot: { + ...value.snapshot, + pending: [ + { + id: revoked, + createdAt: BASE_TIME, + nextAttemptAt: BASE_TIME, + retryCount: 0, + payload: 1, + }, + ...value.snapshot.pending, + ], + pendingCount: value.snapshot.pendingCount + 1, + }, + } + : undefined }), - ).toThrow() - }) + }, + { + name: `the pending count is off by one`, + law: `ledger`, + corrupt: corruptFirst((value) => ({ + ...value, + snapshot: { + ...value.snapshot, + pendingCount: value.snapshot.pendingCount + 1, + }, + })), + }, + { + name: `a duplicate schedule is accepted`, + law: `dedupe`, + corrupt: corruptFirst((value) => + value.accepted === false ? { ...value, accepted: true } : undefined, + ), + }, + { + name: `a selective removal keeps the revoked work`, + law: `retirement`, + corrupt: corruptFirst((value) => + value.removed && value.removed.length > 0 + ? { ...value, removed: [] } + : undefined, + ), + }, + ], }) diff --git a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts index 9de67b52d6..c4a31bff47 100644 --- a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts +++ b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts @@ -1,8 +1,13 @@ -import { expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { NonRetriableError } from '../src/types' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' -import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' +import { + atOracleCheckpoint, + cleanupOfflineOracle, + useControlledClock, +} from './oracle-lifecycle' /** * # May a false browser online hint strand a visible tab's durable work? @@ -29,6 +34,9 @@ import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' * transaction path; public outbox, provider calls, and commit settlement are * compared before and after notification. Baseline main fails at the visible * checkpoint with zero provider calls; the fixture uses no elapsed-time retry. + * Each history runs on a controlled clock (timers and `Date` virtual, + * macrotask turns real), so a checkpoint that a retry backoff would end + * fails at its virtual bound at once. */ function expectedAt(phase: `hidden` | `visible`): { providerCalls: number @@ -40,11 +48,29 @@ function expectedAt(phase: `hidden` | `visible`): { : { providerCalls: 1, outboxEntries: 0, callerState: `fulfilled` } } -declareOracle({ model: [expectedAt] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/OfflineExecutor.ts#OfflineExecutor.getOnlineDetector`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.peekOutbox`, + ], + model: [expectedAt], +}) + +let releaseClock: (() => void) | undefined +beforeEach(() => { + releaseClock = useControlledClock() +}) +afterEach(() => { + releaseClock?.() +}) it.each([`visibilitychange`, `explicit retry`] as const)( `replays a committed outbox write on %s in a visible tab with a false online hint`, async (signal) => { + oracleRoute(`case`) const visibilityListeners = new Set<() => void>() vi.stubGlobal(`navigator`, { ...navigator, onLine: false }) vi.stubGlobal(`document`, { diff --git a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts index c9b8b57cc6..8e7621fef6 100644 --- a/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry-oracle.property.test.ts @@ -1,215 +1,665 @@ import fc from 'fast-check' -import { expect, it, vi } from 'vitest' +import { vi } from 'vitest' import { KeyScheduler } from '../src/executor/KeyScheduler' import { TransactionExecutor } from '../src/executor/TransactionExecutor' import { OutboxManager } from '../src/outbox/OutboxManager' -import { oracleReplayTest } from '../../db/tests/oracle-config' +import { NonRetriableError } from '../src/types' +import { + differs, + oracleProperty, + pass, + useOracleClock, + violation, +} from '../../db/tests/oracle-property' +import { declareOracle } from '../../db/tests/oracle-declaration' import { FakeStorageAdapter } from './harness' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import type { Observation } from '../../db/tests/oracle-property' import type { OfflineTransaction } from '../src/types' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/executor/TransactionExecutor.ts#TransactionExecutor.executeAll`, + `src/executor/TransactionExecutor.ts#TransactionExecutor.clear`, + ], +}) + /** - * # Why does a ready transaction wait? + * # Why does a ready transaction wait, and what stops the queue? + * + * Contract (the package README: "FIFO Sequential Processing", "Retry + * decisions", and the offline-execution settlement section): + * - The executor runs one transaction at a time, in creation order. A later + * transaction does not pass an earlier one that has a retry delay, and a + * retried transaction keeps its FIFO position. + * - Eligible work runs when it becomes eligible: at the head's deadline the + * executor runs the head and every ready transaction behind it, in order, + * until a retriable failure or a storage fault. This is a bounded liveness + * check on a controlled clock, not a wall-clock latency promise; while work + * is pending, timer count and polling strategy are outside the judgment. + * The `nextAttemptAt` + * eligibility boundary itself is the KeyScheduler oracle's + * (`KeyScheduler-oracle.property.test.ts`). + * - A retriable provider failure retries the same transaction after its + * backoff; nothing behind it runs first. + * - A `NonRetriableError` removes its row and rejects its caller with that + * error; queued rows then continue in creation order. + * - An idle executor goes quiet: once every plan has played out, so that no + * transaction is due or waiting to retry, it holds no timer. An executor + * that keeps waking with nothing to do spins the CPU for as long as the app + * runs (maintainer decision, 2026-11-06: an idle spin is a bad bug). + * - Success means the provider returned and the storage adapter acknowledged + * the outbox deletion. If recording the `deletion-pending` phase or + * removing the row fails, the caller rejects with the storage error and + * the executor stops: it processes no queued peer. + * + * Fault injection: each transaction's attempt plan is part of the history. + * The provider fails a planned number of times with a retriable error, then + * succeeds or fails permanently; a successful transaction may also have a + * planned storage fault on its phase write or its deletion. The faults are + * data, so a history replays exactly. * - * The scheduler executes transactions in first-in, first-out order. A later - * transaction must not pass the first transaction when the first transaction - * has a retry delay. + * History grammar: two to five transactions with increasing `createdAt`; only + * the head has a future `nextAttemptAt` (2 to 10,000 ms), and the tail is + * ready. Two is the smallest head-and-tail witness; larger counts test that + * the whole tail drains, and removing either the tail or the delay loses the + * delayed-head history. The delay axis moves the head's eligibility boundary; + * the checkpoints at delay-1 and delay distinguish early and late wakeups, + * including the 2 ms marginal delay. A tail created before the head, + * duplicate IDs, and a ready head are outside the grammar: each changes the + * premise. * - * The executor must execute nothing before the head is eligible. Under the - * controlled virtual clock, advancing to that deadline must execute all - * transactions in queue order and remove them from the outbox. + * Model: the attempt order and outcomes follow from the plans alone, without + * the scheduler, timers, or the outbox. At the deadline, attempts run until + * the first retriable failure (whose backoff is at least a second) or a + * storage fault; after every timer has run, every plan has played out up to + * the first storage fault, and the executor holds no timer. * - * The package README's FIFO sequential-processing and automatic-retry - * contracts supply the order and eventual-retry laws. The established - * `KeyScheduler-oracle.property.test.ts` supplies the `nextAttemptAt` eligibility - * boundary. - * Advancing fake timers to that boundary is a bounded executor liveness check, - * not a user-facing wall-clock latency guarantee. Timer count and polling - * strategy are implementation details outside this oracle's judgment. - * This oracle covers an online executor with successful mutation functions, - * one delayed head, and ready tails. It does not judge failed mutation - * functions, leadership changes, or real browser timer scheduling. + * Driver: a real `TransactionExecutor`, `KeyScheduler`, and `OutboxManager` + * over fake storage that fails as planned, with a controlled virtual clock + * and jitter off. It records provider calls, the outbox, and each caller's + * settlement at each checkpoint, and after the last one the number of timers + * the executor still holds. It runs the retry timers one at a time, at most + * `TIMER_STEPS` of them, so an executor that never goes quiet cannot keep + * the driver busy forever; the idle law then judges the timers it left. * - * Input grammar: 2–5 distinct transactions have increasing `createdAt`; only - * the head has a future `nextAttemptAt` (2–10,000 ms). A count of 2 is the - * smallest head/tail witness; larger counts challenge complete tail draining. - * The delay axis moves the boundary while preserving the order law. Every - * generated pair reconstructs the delayed-head/ready-tail history. Removing - * either the tail or delay loses that history. A tail before the head by - * `createdAt`, duplicate IDs, or a ready head is outside this grammar; each - * changes the premise. The checkpoints at delay−1 and delay distinguish early - * and late wakeups, including the 2 ms marginal delay. + * Reach comes from what production did, never from the grammar's labels. The + * four states that the model can predict from the plans are state probes + * (glossary: state probe): production marks each one where its branch runs, + * `predict` says from the plans alone whether a history enters it, and reach + * agreement checks the two in every history. They are `retry before a queued + * peer` (a retriable failure while a transaction waits behind it), + * `permanent failure before a peer` (a non-retriable failure with a row + * behind it), `phase write fault` (the deletion-pending marker write fails), + * and `fault before a queued peer` (a storage fault stops the executor with a + * row behind it). `deletion fault` is reported by the storage fake when it + * rejects the removal that the executor issued: production reached the + * removal and the storage refused it. `retried head` and `retried tail` are + * measured only: the driver reports each provider call that fails retriably, + * and `queue drained` when the outbox ends empty, the history where an idle + * executor has nothing left at all. * - * fast-check varies the queue length and the retry delay. `expectedStateAt` - * models time without using the scheduler, timers, or the outbox. + * Limits: an online executor with one mutation function; no leadership + * changes, no `shouldRetry` or `beforeRetry` hooks, no storage fault on a + * permanent failure's removal, and no real browser timers. Backoff lengths + * are not judged, only that a retry waits past the deadline cut. */ -type ExpectedState = { - calls: Array - outboxCount: number +// --------------------------------------------------------------------------- +// History +// --------------------------------------------------------------------------- + +type Plan = { + /** Retriable provider failures before the final attempt. */ + failures: number + final: `success` | `permanent` + /** A storage fault after a successful provider call. */ + storageFault: `none` | `phase` | `delete` } +type History = { delay: number; plans: Array } -const FIFO_RETRY_PROPERTY = `offline.fifo-retry` -const fifoRetryOracle = readOfflineOracleConfig({ - property: FIFO_RETRY_PROPERTY, - defaultRuns: 30, +const plan: fc.Arbitrary = fc.oneof( + // Most transactions succeed at once, so a queue often drains past a fault. + { + weight: 3, + arbitrary: fc.record({ + failures: fc.constant(0), + final: fc.constant(`success` as const), + storageFault: fc.constant(`none` as const), + }), + }, + { + weight: 2, + arbitrary: fc.record({ + failures: fc.integer({ min: 0, max: 2 }), + final: fc.constantFrom(`success` as const, `permanent` as const), + storageFault: fc.constant(`none` as const), + }), + }, + { + weight: 1, + arbitrary: fc.record({ + failures: fc.integer({ min: 0, max: 1 }), + final: fc.constant(`success` as const), + storageFault: fc.constantFrom(`phase` as const, `delete` as const), + }), + }, +) +const fifoHistory: fc.Arbitrary = fc.record({ + delay: fc.integer({ min: 2, max: 10000 }), + plans: fc.array(plan, { minLength: 2, maxLength: 5 }), }) -function expectedStateAt( - elapsed: number, - delay: number, - transactionIds: ReadonlyArray, -): ExpectedState { - const deadlinePassed = elapsed >= delay - return { - calls: deadlinePassed ? [...transactionIds] : [], - outboxCount: deadlinePassed ? 0 : transactionIds.length, +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +type Settlement = { + id: string + outcome: `fulfilled` | `rejected` + message?: string +} +type State = { + calls: Array + outbox: Array + settled: Array +} +const idOf = (index: number) => `tx-${index}` +const retriable = (index: number, attempt: number) => + `retriable failure ${idOf(index)} attempt ${attempt}` +const permanent = (index: number) => `permanent failure ${idOf(index)}` +const storageFailure = (index: number) => `storage failed for ${idOf(index)}` + +/** + * What the public observations show after the plans have run, either only + * up to the first retriable failure (`untilRetry`, the deadline cut) or to + * the end. + */ +function playOut({ plans }: History, untilRetry: boolean): State { + const calls: Array = [] + const settled: Array = [] + const remaining = new Set(plans.map((_, index) => idOf(index))) + for (const [index, { failures, final, storageFault }] of plans.entries()) { + const id = idOf(index) + if (untilRetry && failures > 0) { + calls.push(id) + break + } + for (let attempt = 0; attempt <= failures; attempt++) calls.push(id) + if (final === `permanent`) { + remaining.delete(id) + settled.push({ id, outcome: `rejected`, message: permanent(index) }) + continue + } + if (storageFault !== `none`) { + // The row stays, and the executor stops before any queued peer. + settled.push({ id, outcome: `rejected`, message: storageFailure(index) }) + break + } + remaining.delete(id) + settled.push({ id, outcome: `fulfilled` }) } + return { calls, outbox: [...remaining], settled } } -function expectState(actual: ExpectedState, expected: ExpectedState): void { - expect(actual.calls).toEqual(expected.calls) - expect(actual.outboxCount).toBe(expected.outboxCount) +/** Timers an executor with nothing left to run may hold: none (idle law). */ +const idleTimers = 0 + +function fifoModel(history: History) { + const queued: State = { + calls: [], + outbox: history.plans.map((_, index) => idOf(index)), + settled: [], + } + return new Map([ + [`before deadline`, queued], + [`just before deadline`, queued], + [`at deadline`, playOut(history, true)], + [`after every retry`, playOut(history, false)], + ]) } -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of FIFO_RETRY_PROPERTY. -async function checkFifoRetry(seed: number | undefined): Promise { - await fc.assert( - fc.asyncProperty( - fc.integer({ min: 2, max: 5 }), - fc.integer({ min: 2, max: 10000 }), - async (count, delay) => { - let clearExecutor: (() => void) | undefined - let primaryFailure: unknown - let failed = false - try { - vi.useFakeTimers() - const scheduler = new KeyScheduler() - const outbox = new OutboxManager(new FakeStorageAdapter(), {}) - const calls: Array = [] - - // Record each transaction when the production executor runs it. - const executor = new TransactionExecutor( - scheduler, - outbox, - { - collections: {}, - mutationFns: { - persist: ({ transaction }) => { - calls.push(transaction.id) - return Promise.resolve() - }, - }, - }, - { - isOfflineEnabled: true, - resolveTransaction: () => {}, - rejectTransaction: () => {}, - registerRestorationTransaction: () => {}, - isOnline: () => true, - }, - ) - clearExecutor = () => executor.clear() - const transactions: Array = Array.from( - { length: count }, - (_unused, index) => ({ - id: `tx-${index}`, - mutationFnName: `persist`, - mutations: [], - keys: [], - idempotencyKey: `key-${index}`, - createdAt: new Date(index), - nextAttemptAt: Date.now() + (index === 0 ? delay : 0), - retryCount: 0, - version: 1, - }), - ) - const transactionIds = transactions.map( - (transaction) => transaction.id, - ) - - for (const transaction of transactions) { - await outbox.add(transaction) - scheduler.schedule(transaction) - } - await executor.executeAll() - - // A ready tail waits for the delayed head. The virtual-clock cuts - // distinguish early execution from failure to wake when eligible. - const beforeDeadline = expectedStateAt(0, delay, transactionIds) - const observedBeforeDeadline = { - calls: [...calls], - outboxCount: await outbox.count(), - } - expectState(observedBeforeDeadline, beforeDeadline) - // A scheduler that lets a ready tail bypass the delayed head - // reaches this checkpoint with an extra call. Calibrate the same - // comparison against that plausible wrong observation. - expect(() => - expectState( - { ...observedBeforeDeadline, calls: [transactionIds[1]!] }, - beforeDeadline, - ), - ).toThrow() - await vi.advanceTimersByTimeAsync(delay - 1) - const nearDeadline = expectedStateAt(delay - 1, delay, transactionIds) - expectState( - { calls: [...calls], outboxCount: await outbox.count() }, - nearDeadline, - ) - await vi.advanceTimersByTimeAsync(1) - const atDeadline = expectedStateAt(delay, delay, transactionIds) - const observedAtDeadline = { - calls: [...calls], - outboxCount: await outbox.count(), - } - expectState(observedAtDeadline, atDeadline) - // A scheduler that never wakes at eligibility leaves the whole - // queue untouched at this controlled-clock checkpoint. - expect(() => - expectState( - { calls: [], outboxCount: transactionIds.length }, - atDeadline, - ), - ).toThrow() - } catch (error) { - primaryFailure = error - failed = true - } +// States. A probe-bound state is predicted by `predictedStates` and marked by +// a production probe; the other two are reported by the driver. +const retryBeforePeer = `retry before a queued peer` +const permanentBeforePeer = `permanent failure before a peer` +const phaseWriteFault = `phase write fault` +const faultBeforePeer = `fault before a queued peer` +const deletionFault = `deletion fault` +const measuredStates = [ + `retried head`, + `retried tail`, + `queue drained`, +] as const - const cleanupFailures: Array = [] - const cleanups: Array<[string, (() => void) | undefined]> = [ - [`executor.clear`, clearExecutor], - [`real timer restore`, () => vi.useRealTimers()], - ] - for (const [name, cleanup] of cleanups) { - if (!cleanup) continue - try { - cleanup() - } catch (error) { - cleanupFailures.push(new Error(`${name} failed`, { cause: error })) - } - } - if (cleanupFailures.length > 0) { - throw new AggregateError( - cleanupFailures, - `FIFO retry oracle cleanup failed`, - { cause: failed ? primaryFailure : undefined }, - ) +/** + * Which probe-bound states a history enters, from its plans alone. The + * executor plays the plans in order and stops at the first storage fault + * (`stop`), so only the plans up to it run. A plan at `index` has a queued + * peer when `index < last`: the rows behind it are still waiting. + */ +function predictedStates({ plans }: History): Record { + const last = plans.length - 1 + const stop = plans.findIndex( + (entry) => entry.final === `success` && entry.storageFault !== `none`, + ) + const played = stop < 0 ? plans : plans.slice(0, stop + 1) + return { + [retryBeforePeer]: played.some( + (entry, index) => entry.failures > 0 && index < last, + ), + [permanentBeforePeer]: played.some( + (entry, index) => entry.final === `permanent` && index < last, + ), + [phaseWriteFault]: stop >= 0 && plans[stop]!.storageFault === `phase`, + [faultBeforePeer]: stop >= 0 && stop < last, + } +} + +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- + +// Timers the driver runs after the deadline. A history's retries need a few +// dozen; an executor that keeps arming timers is cut off here (in a few +// milliseconds of virtual time) and judged by the idle law. Raise it if a +// legal history needs more. +const TIMER_STEPS = 1000 + +async function driveFifo( + { delay, plans }: History, + observe: (checkpoint: string, value: unknown) => void, +) { + let executor: TransactionExecutor | undefined + let warn: { mockRestore: () => void } | undefined + let primary: unknown + let failed = false + let clock: { release: () => void } | undefined + try { + // The framework's controlled clock: a history whose timers never go + // quiet fails under law `bounded clock`. + clock = useOracleClock() + // A storage fault stops the executor, which logs the failed retry batch. + warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + // Fails the planned phase write or deletion for one transaction. + const storage = new (class extends FakeStorageAdapter { + override async set(key: string, value: string): Promise { + const index = plans.findIndex((_, i) => key.endsWith(idOf(i))) + if ( + index >= 0 && + plans[index]!.storageFault === `phase` && + value.includes(`"deletion-pending"`) + ) + throw new Error(storageFailure(index)) + return super.set(key, value) + } + override async delete(key: string): Promise { + const index = plans.findIndex((_, i) => key.endsWith(idOf(i))) + if (index >= 0 && plans[index]!.storageFault === `delete`) { + observe(`reach`, deletionFault) + throw new Error(storageFailure(index)) } - if (failed) throw primaryFailure + return super.delete(key) + } + })() + const outbox = new OutboxManager(storage, {}) + const scheduler = new KeyScheduler() + const calls: Array = [] + const attempts = new Map() + const settled: Array = [] + executor = new TransactionExecutor( + scheduler, + outbox, + { + collections: {}, + jitter: false, + mutationFns: { + persist: ({ transaction }) => { + const id = transaction.id + calls.push(id) + const attempt = attempts.get(id) ?? 0 + attempts.set(id, attempt + 1) + const index = Number(id.slice(3)) + const { failures, final } = plans[index]! + // An attempt the plan does not have never settles, so a runaway + // executor stops at its first extra call, which the trace shows. + if (attempt > failures) return new Promise(() => {}) + if (attempt < failures) { + observe(`reach`, index === 0 ? `retried head` : `retried tail`) + return Promise.reject(new Error(retriable(index, attempt))) + } + if (final === `permanent`) + return Promise.reject(new NonRetriableError(permanent(index))) + return Promise.resolve() + }, + }, }, - ), - oracleLaneOptions(fifoRetryOracle, seed), + { + isOfflineEnabled: true, + resolveTransaction: (id) => settled.push({ id, outcome: `fulfilled` }), + rejectTransaction: (id, error) => + settled.push({ id, outcome: `rejected`, message: error.message }), + registerRestorationTransaction: () => {}, + isOnline: () => true, + }, + ) + const record = async (checkpoint: string) => + observe(checkpoint, { + calls: [...calls], + outbox: (await outbox.getAll()).map((entry) => entry.id), + settled: [...settled], + } satisfies State) + + for (const [index] of plans.entries()) { + const transaction: OfflineTransaction = { + id: idOf(index), + mutationFnName: `persist`, + mutations: [], + keys: [], + idempotencyKey: `key-${index}`, + createdAt: new Date(index), + nextAttemptAt: Date.now() + (index === 0 ? delay : 0), + retryCount: 0, + version: 1, + } + await outbox.add(transaction) + scheduler.schedule(transaction) + } + await executor.executeAll() + await record(`before deadline`) + await vi.advanceTimersByTimeAsync(delay - 1) + await record(`just before deadline`) + await vi.advanceTimersByTimeAsync(1) + await record(`at deadline`) + // Backoff doubles from a second to a minute; run every retry timer, one + // at a time, up to the step budget. + for (let step = 0; step < TIMER_STEPS && vi.getTimerCount() > 0; step++) + await vi.advanceTimersToNextTimerAsync() + await record(`after every retry`) + if ((await outbox.getAll()).length === 0) observe(`reach`, `queue drained`) + observe(`idle`, vi.getTimerCount()) + } catch (error) { + primary = error + failed = true + } + // Each release runs even when another fails; a cleanup failure keeps the + // driver's own error as its cause. + const failures: Array = [] + for (const release of [ + () => executor?.clear(), + () => warn?.mockRestore(), + () => clock?.release(), + ]) + try { + release() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) + throw new AggregateError(failures, `FIFO retry driver cleanup failed`, { + cause: failed ? primary : undefined, + }) + if (failed) throw primary +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- +// +// At each checkpoint the provider calls, the outbox, and the settlements +// equal the model's. Calls that are not a prefix of the full attempt order +// break the FIFO law; a correct prefix of the wrong length at a cut breaks +// the progress law (a late wakeup, or a queue that stalls); settlements and the outbox after a fault are the stop law. +// After every retry, a timer the executor still holds breaks the idle law. + +function checkFifo( + _history: History, + trace: ReadonlyArray, + owed: ReturnType, +) { + const order = owed.get(`after every retry`)!.calls + for (const [checkpoint, expected] of owed) { + const seen = trace.find((entry) => entry.checkpoint === checkpoint) + ?.value as State | undefined + if (!seen) + return violation( + checkpoint, + `the driver recorded no "${checkpoint}"`, + `trace`, + ) + // Calls in the full attempt order, but too few or too many for this cut, + // break progress; any other difference breaks FIFO order. + const callLaw = seen.calls.every((id, index) => id === order[index]) + ? `progress` + : `fifo` + const wrong = + differs( + checkpoint, + callLaw, + seen.calls, + expected.calls, + `provider calls differ from creation order and the attempt plans`, + ) ?? + differs( + checkpoint, + `settlement`, + seen.settled, + expected.settled, + `caller settlements differ from the plans`, + ) ?? + differs( + checkpoint, + `stop`, + seen.outbox, + expected.outbox, + `the outbox differs from the plans`, + ) + if (wrong) return wrong + } + return ( + differs( + `idle`, + `idle`, + trace.find((entry) => entry.checkpoint === `idle`)?.value, + idleTimers, + `the executor still holds timers with nothing left to run`, + ) ?? pass ) } -oracleReplayTest(it).each([20260916])( - `waits for the FIFO head and executes eligible work at its deadline (seed %s)`, - checkFifoRetry, -) -oracleReplayTest(it, FIFO_RETRY_PROPERTY).each([undefined])( - `waits for the FIFO head and executes eligible work at its deadline (seed %s)`, - checkFifoRetry, -) +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +const ok: Plan = { failures: 0, final: `success`, storageFault: `none` } + +oracleProperty({ + id: `offline.fifo-retry`, + law: { + statement: `The executor runs transactions one at a time in creation order: a ready tail waits for a delayed or retrying head, eligible work runs when its deadline passes, a permanent failure lets queued rows continue, a failed phase write or deletion rejects its caller with the storage error and stops the queue, and an executor with nothing left to run holds no timer.`, + authority: `packages/offline-transactions/README.md: FIFO Sequential Processing, Retry decisions, and offline-execution settlement; the eligibility boundary from KeyScheduler-oracle.property.test.ts; the idle law is the maintainer's decision of 2026-11-06 (an idle spin is a bad bug)`, + limits: `An online executor with one mutation function, no retry hooks, and jitter off; no leadership changes; no storage fault on a permanent failure's removal; backoff lengths are not judged, and timer counts and polling strategy only once nothing is left to run; at most 1000 retry timers after the deadline; liveness on a controlled clock, not wall-clock latency.`, + owner: `Offline execution`, + }, + histories: { + arbitrary: fifoHistory, + states: [ + { + name: retryBeforePeer, + probe: `offline-transactions#retry-before-peer`, + }, + { + name: permanentBeforePeer, + probe: `offline-transactions#permanent-failure-before-peer`, + }, + { + name: phaseWriteFault, + probe: `offline-transactions#phase-write-fault`, + }, + { + name: faultBeforePeer, + probe: `offline-transactions#fault-before-peer`, + }, + deletionFault, + ], + measured: measuredStates, + runs: 60, + fixedSeeds: [20260916], + // Each required state gets a history with a queued peer behind the event: + // the laws need a peer that waits, runs, or stays in the outbox. + constructed: [ + { + name: `a phase write fault before a queued peer`, + enters: [phaseWriteFault, faultBeforePeer], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { ...ok }, + { failures: 0, final: `success`, storageFault: `phase` }, + ...generated.plans.slice(0, 3), + ], + }), + }, + { + name: `a deletion fault before a queued peer`, + enters: [deletionFault, faultBeforePeer], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { ...ok }, + { failures: 0, final: `success`, storageFault: `delete` }, + ...generated.plans.slice(0, 3), + ], + }), + }, + { + name: `a retry before a queued peer`, + enters: [retryBeforePeer], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { failures: 1, final: `success`, storageFault: `none` }, + { ...ok }, + ...generated.plans.slice(0, 3), + ], + }), + }, + { + name: `a permanent failure before a queued peer`, + enters: [permanentBeforePeer], + build: (generated: History): History => ({ + delay: generated.delay, + plans: [ + { ...ok }, + { failures: 0, final: `permanent`, storageFault: `none` }, + ...generated.plans.slice(0, 2), + ], + }), + }, + ], + fixed: [ + { + name: `a ready tail waits for a delayed head with the smallest delay`, + history: { delay: 2, plans: [{ ...ok }, { ...ok }] }, + }, + { + name: `a retried head keeps its place`, + enters: [retryBeforePeer, `retried head`], + history: { + delay: 500, + plans: [ + { failures: 2, final: `success`, storageFault: `none` }, + { ...ok }, + { ...ok }, + ], + }, + }, + { + name: `a permanent failure lets the queue continue`, + enters: [permanentBeforePeer], + history: { + delay: 10, + plans: [ + { ...ok }, + { failures: 1, final: `permanent`, storageFault: `none` }, + { ...ok }, + ], + }, + }, + { + name: `a failed deletion stops the queue`, + enters: [deletionFault, faultBeforePeer], + history: { + delay: 10, + plans: [ + { failures: 0, final: `success`, storageFault: `delete` }, + { ...ok }, + ], + }, + }, + ], + }, + model: fifoModel, + // Probe-bound states are predicted from the plans, never from the trace. + predict: predictedStates, + driver: driveFifo, + check: checkFifo, + // Wrong answers the check must reject: a ready tail that passes the + // delayed head, a scheduler that never wakes at eligibility, a queue + // that keeps running after a storage fault, and an idle executor that + // keeps a timer armed. + calibrations: [ + { + name: `an idle executor keeps a timer armed`, + law: `idle`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `idle` ? { ...entry, value: 1 } : entry, + ), + }, + { + name: `a ready tail passes the delayed head`, + law: `fifo`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `before deadline` + ? { + ...entry, + value: { ...(entry.value as State), calls: [idOf(1)] }, + } + : entry, + ), + }, + { + name: `the head never wakes at its deadline`, + law: `progress`, + // The whole queue is untouched: no calls, every row still stored. + corrupt: (trace) => { + const queued = trace.find( + ({ checkpoint }) => checkpoint === `before deadline`, + )?.value as State | undefined + return trace.map((entry) => + entry.checkpoint === `at deadline` && queued + ? { ...entry, value: queued } + : entry, + ) + }, + }, + { + name: `the queue continues past a storage fault`, + law: `stop`, + corrupt: (trace) => { + const last = trace.find( + ({ checkpoint }) => checkpoint === `after every retry`, + )?.value as State | undefined + if (!last || last.outbox.length < 2) return undefined + return trace.map((entry) => + entry.checkpoint === `after every retry` + ? { ...entry, value: { ...last, outbox: last.outbox.slice(0, 1) } } + : entry, + ) + }, + }, + ], +}) diff --git a/packages/offline-transactions/tests/harness.ts b/packages/offline-transactions/tests/harness.ts index d6e9b9a552..ba5737a628 100644 --- a/packages/offline-transactions/tests/harness.ts +++ b/packages/offline-transactions/tests/harness.ts @@ -290,5 +290,9 @@ export function gate() { return { promise, resolve } } -/** Yields one macrotask, after the microtasks that the step queued. */ -export const turn = () => new Promise((resolve) => setTimeout(resolve, 0)) +/** + * Yields one macrotask, after the microtasks that the step queued. + * `setImmediate` yields it without the 1 ms clamp of `setTimeout(0)`, which + * left schedule-heavy oracles mostly idle. + */ +export const turn = () => new Promise((resolve) => setImmediate(resolve)) 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 a0f465a79d..29fe4b52b3 100644 --- a/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/interruption-settlement-oracle.property.test.ts @@ -1,22 +1,36 @@ import fc from 'fast-check' -import { expect, it, vi } from 'vitest' +import { vi } from 'vitest' import { NonRetriableError } from '../src/types' import { - oraclePropertyOptions, - oracleReplayTest, -} from '../../db/tests/oracle-config' + differs, + oracleProperty, + pass, + scheduleFrom, + violation, + withinTurns, +} from '../../db/tests/oracle-property' import { declareOracle } from '../../db/tests/oracle-declaration' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import { - atOracleCheckpoint, captureUnhandledRejections, cleanupOfflineOracle, } from './oracle-lifecycle' +import type { Observation } from '../../db/tests/oracle-property' import type { OnlineDetector } from '../src/types' import type { TestItem } from './harness' +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/OfflineExecutor.ts#startOfflineExecutor`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.dispose`, + ], +}) + /** - * # Does an interruption at any microtask turn keep each offline transaction's + * # Does an interruption at any point keep each offline transaction's * settlement and durable ownership? * * Contract and source: the README and the settlement and leadership oracles. @@ -28,52 +42,63 @@ import type { TestItem } from './harness' * the provider fulfilled and the outbox row was durably removed. * * The other offline oracles step between named gates or full flushes. This one - * moves a single interruption across individual microtask turns of the - * commit pipeline (`persistTransaction`, outbox admission, scheduling, the - * provider call, the phase write and the deletion), where a removed `await` - * or a reordered step would change what the interruption sees. + * moves a single interruption across the commit pipeline (`persistTransaction`, + * outbox admission, scheduling, the provider call, the phase write and the + * deletion), where a removed `await` or a reordered step would change what the + * interruption sees. Two timings: + * + * - **Immediate:** the fake storage answers each operation in one turn and the + * provider answers within its call; the interruption lands after `turn` + * microtask turns (0-40, favoring the first few, where admission happens). + * - **Scheduled:** storage operations and provider responses wait for a data + * schedule. Storage completes its operations in order, as one store does; + * the provider may answer before or after any pending storage operation. + * The interruption lands after `steps` releases and then `turn` microtask + * turns, so it can fall while a write or an answer is still outstanding. * * History grammar: one to three manual transactions on disjoint keys, each * with a provider outcome (fulfil, or reject with `NonRetriableError`), commit - * back to back. After `turn` microtask turns (0-40, favoring the first few, - * where admission happens) one interruption occurs: - * leadership loss, going offline, or disposal. Recovery then restores the - * condition: leadership returns to the same executor, the detector goes back - * online, or, after disposal and once issued work has settled, a fresh - * executor starts over the same storage. + * back to back. One interruption occurs: leadership loss, going offline, or + * disposal. Recovery then restores the condition: leadership returns to the + * same executor, the detector goes back online, or, after disposal and once + * issued work has settled, a fresh executor starts over the same storage. * - * Model and refinement check, all from recorded public observations: - * - Admission: a commit that rejects with `NonRetriableError` before its - * provider call was not admitted; it must never reach the provider. Only - * the current leader admits work, so after leadership loss or disposal no + * The laws, judged from recorded public observations: + * - `admission`: a commit that rejects with `NonRetriableError` before its + * provider call was not admitted, and never reaches the provider. Only the + * current leader admits work, so after leadership loss or disposal no * outbox write for a new row may start. The admission check and the write * run in one synchronous step, so a write that starts after the * interruption means admission passed after it. Going offline does not * refuse admission. - * - Fencing: no provider call starts between the interruption and recovery. - * - Once: every admitted ID reaches the provider exactly once across both + * - `fencing`: no provider call starts between the interruption and recovery. + * - `once`: every admitted ID reaches the provider exactly once across both * executors, and calls follow commit order. - * - Settlement: after leadership returns or the detector goes online, every + * - `settlement`: after leadership returns or the detector goes online, every * admitted commit settles with its provider outcome. After disposal, the * old executor's waiter may stay pending (disposal ends it), but a settled * commit must still match its outcome. - * - Durability: when a commit fulfils, its row is already gone from storage, - * and after recovery the outbox is empty. - * - No rejection escapes as an unhandled process rejection. + * - `durability`: when a commit fulfils, its row is already gone from + * storage, and after recovery the outbox is empty. + * - `unhandled`: no rejection escapes as an unhandled process rejection. * - * Reach: each history reports where the interruption landed for each - * transaction: before admission, after admission but before its provider - * call, or after the call. The campaign requires every reachable position for - * every interruption (going offline cannot refuse admission), so the turn - * range cannot silently fall entirely before or after the pipeline. + * Reach: where the interruption landed for each transaction: before + * admission (`refused`), after admission but before its provider call started + * (`queued`), or after the call started (`issued`), whether or not the + * provider has answered, per interruption. Fixed histories enter every + * reachable landing; going offline cannot refuse admission. * * Limits: disjoint keys only, so sibling rollback is the settlement oracle's; - * the provider answers within its call, and fake storage answers each - * operation in one turn, so this proves ordering against this adapter, not a - * native storage engine. Overlapping old and new executors are not generated: - * exactly-once execution across independent leaders is not promised. + * fake storage over one store, so this proves ordering against this adapter, + * not a native storage engine. Overlapping old and new executors are not + * generated: exactly-once execution across independent leaders is not + * promised. */ +// --------------------------------------------------------------------------- +// History +// --------------------------------------------------------------------------- + type Outcome = `fulfil` | `reject` type Interruption = `leadership` | `offline` | `dispose` type Landing = `refused` | `queued` | `issued` @@ -81,12 +106,10 @@ type History = { outcomes: Array interruption: Interruption turn: number + /** Null for immediate storage and provider; otherwise a data schedule. */ + schedule: { choices: Array; steps: number } | null } -// 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`), { minLength: 1, @@ -102,8 +125,26 @@ const history: fc.Arbitrary = fc.record({ fc.integer({ min: 0, max: 4 }), fc.integer({ min: 0, max: 40 }), ), + schedule: fc.option( + fc.record({ + choices: fc.array(fc.nat(3), { maxLength: 12 }), + steps: fc.nat(8), + }), + { freq: 2 }, + ), }) +// --------------------------------------------------------------------------- +// Production driver +// --------------------------------------------------------------------------- +// +// The driver commits the transactions back to back, lets the pipeline run +// for the history's timing, interrupts, lets issued work settle, recovers, +// and lets the outbox drain. It records the phase each outbox admission +// write and each provider call started in, each commit's result, the stored +// keys when each commit fulfilled, the outbox after recovery, and unhandled +// rejections. + class ControlledDetector implements OnlineDetector { online = true private listeners = new Set<() => void>() @@ -126,40 +167,77 @@ class ControlledDetector implements OnlineDetector { } } -const settle = async () => { - for (let index = 0; index < 10; index++) - await new Promise((resolve) => setTimeout(resolve, 0)) +type Phase = `before` | `fenced` | `after` +type CommitResult = + | { kind: `fulfilled`; storedAtFulfil: Array } + | { kind: `non-retriable`; message: string } + | { kind: `error`; message: string } + | { kind: `pending` } +type Run = { + admissions: Array + calls: Array<{ id: string; phase: Phase }> + commits: Array<{ id: string; result: CommitResult }> + outbox: Array + unhandled: Array } -async function checkInterruption({ - outcomes, - interruption, - turn, -}: History): Promise> { - // The phase in which each provider call or outbox write started. - let phase: `before` | `fenced` | `after` = `before` - // Outbox writes of rows not stored before, with the phase each started in. - const admissions: Array<`before` | `fenced` | `after`> = [] +async function driveInterruption( + { outcomes, interruption, turn, schedule: timing }: History, + observe: (checkpoint: string, value: unknown) => void, +) { + // A permanent provider failure logs once by design; this law ignores logs. + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const schedule = timing ? scheduleFrom(timing.choices) : undefined + // Settling yields ten macrotask turns. `setImmediate` yields a turn + // without the timer clamp of `setTimeout(0)`, which made this oracle the + // slowest owner in the gap check: the same reach at the fixed seed, about + // half the time. Production's only timer, the retry timer, waits its own + // delay either way. + const settle = async () => { + for (let index = 0; index < 10; index++) { + await schedule?.waitIdle() + await new Promise((resolve) => setImmediate(resolve)) + } + } + let phase: Phase = `before` + const admissions: Array = [] + // Under a schedule, an operation's effect waits for its release; storage + // completes its operations in the order they were made. + const later = (label: string, effect: () => Promise) => + schedule + ? schedule.schedule(undefined, label, `storage`).then(effect) + : effect() const storage = new (class extends FakeStorageAdapter { override async set(key: string, value: string): Promise { if (!(key in this.snapshot())) admissions.push(phase) - return super.set(key, value) + return later(`storage set ${key}`, () => super.set(key, value)) + } + override async delete(key: string): Promise { + return later(`storage delete ${key}`, () => super.delete(key)) } })() const unhandled = captureUnhandledRejections() - // Provider calls from either executor, in order, with the phase each - // started in. - const calls: Array<{ id: string; phase: `before` | `fenced` | `after` }> = [] - const outcomeById = new Map() - const mutationFn = (params: { - transaction: { id: string } - idempotencyKey: string - }) => { - const id = params.transaction.id + const calls: Array<{ id: string; phase: Phase }> = [] + // Transaction IDs are random, so the trace names each transaction by its + // commit position, here and in storage keys. + const names = new Map() + const named = (text: string) => + [...names].reduce( + (renamed, [id, name]) => renamed.replaceAll(id, name), + text, + ) + const mutationFn = (params: { transaction: { id: string } }) => { + const id = names.get(params.transaction.id)! calls.push({ id, phase }) - if (outcomeById.get(id) === `reject`) - throw new NonRetriableError(`provider rejected ${id}`) - return { ok: true } + const answer = () => { + if (outcomes[Number(id.slice(3))] === `reject`) + throw new NonRetriableError(`provider rejected ${id}`) + return { ok: true } + } + // Under a schedule, the answer waits for its release. + return schedule + ? schedule.schedule(undefined, `provider ${id}`, `provider`).then(answer) + : answer() } const detector = new ControlledDetector() const first = createTestOfflineEnvironment({ @@ -170,16 +248,13 @@ async function checkInterruption({ let second: ReturnType | undefined const commits: Array<{ id: string - outcome: Outcome - // A snapshot of the stored keys when the commit fulfilled. - storedAtFulfil?: Array - result: Promise<`fulfilled` | unknown> - settled: boolean + result: CommitResult + settled: Promise }> = [] let hasPrimaryFailure = false try { await first.waitForLeader() - for (const [index, outcome] of outcomes.entries()) { + for (const index of outcomes.keys()) { const offline = first.executor.createOfflineTransaction({ mutationFnName: first.mutationFnName, autoCommit: false, @@ -191,29 +266,32 @@ async function checkInterruption({ updatedAt: new Date(1700000000000), } const transaction = offline.mutate(() => first.collection.insert(row)) - outcomeById.set(transaction.id, outcome) + names.set(transaction.id, `tx ${index}`) const entry: (typeof commits)[number] = { - id: transaction.id, - outcome, - result: Promise.resolve(), - settled: false, + id: `tx ${index}`, + result: { kind: `pending` }, + settled: Promise.resolve(), } - entry.result = offline.commit().then( + entry.settled = offline.commit().then( () => { - entry.settled = true - entry.storedAtFulfil = Object.keys(storage.snapshot()) - return `fulfilled` + entry.result = { + kind: `fulfilled`, + storedAtFulfil: Object.keys(storage.snapshot()).map(named), + } }, (error: unknown) => { - entry.settled = true - return error + entry.result = + error instanceof NonRetriableError + ? { kind: `non-retriable`, message: named(error.message) } + : { kind: `error`, message: named(String(error)) } }, ) commits.push(entry) } - // Inline, so turn 0 interrupts in the same task as the commits and each // later turn is exactly one microtask. + for (let index = 0; index < (timing?.steps ?? 0); index++) + await schedule!.waitOne() for (let index = 0; index < turn; index++) await Promise.resolve() phase = `fenced` if (interruption === `leadership`) first.leader.setLeader(false) @@ -229,75 +307,21 @@ async function checkInterruption({ await second.waitForLeader() } await settle() - - const results = await atOracleCheckpoint( - Promise.all( - commits.map((entry) => - entry.settled || interruption !== `dispose` - ? entry.result - : Promise.resolve(`pending`), - ), - ), - `commits after recovery`, - ) - - expect( - calls.filter((call) => call.phase === `fenced`), - `provider calls between interruption and recovery`, - ).toEqual([]) - if (interruption !== `offline`) - expect( - admissions.filter((started) => started === `fenced`), - `admissions after the interruption`, - ).toEqual([]) - const calledIds = calls.map((call) => call.id) - expect(new Set(calledIds).size, `provider calls per ID`).toBe( - calledIds.length, - ) - const landings: Array = [] - for (const [index, entry] of commits.entries()) { - const result = results[index] - const called = calledIds.includes(entry.id) - const notAdmitted = !called && result instanceof NonRetriableError - landings.push( - notAdmitted - ? `refused` - : calls.find((call) => call.id === entry.id)?.phase === `before` - ? `issued` - : `queued`, - ) - if (notAdmitted) continue - expect(called, `admitted ${entry.id} reached the provider`).toBe(true) - if (result === `pending`) { - expect(interruption, `only disposal leaves a waiter pending`).toBe( - `dispose`, - ) - continue - } - if (entry.outcome === `fulfil`) { - expect(result, `commit of ${entry.id}`).toBe(`fulfilled`) - expect( - entry.storedAtFulfil?.some((key) => key.includes(entry.id)), - `row of ${entry.id} stored when its commit fulfilled`, - ).toBe(false) - } else { - expect(result, `commit of ${entry.id}`).toBeInstanceOf( - NonRetriableError, - ) - expect((result as Error).message).toBe(`provider rejected ${entry.id}`) - } - } - const order = commits.map((entry) => entry.id) - expect(calledIds, `provider calls follow commit order`).toEqual( - order.filter((id) => calledIds.includes(id)), - ) - expect(Object.keys(storage.snapshot()), `outbox after recovery`).toEqual([]) - expect(unhandled.rejections, `unhandled rejections`).toEqual([]) - return landings + // A waiter that never settles stays pending; the check decides whether + // that is allowed. + for (const entry of commits) await withinTurns(entry.settled, 20) + observe(`run`, { + admissions, + calls, + commits: commits.map(({ id, result }) => ({ id, result })), + outbox: Object.keys(storage.snapshot()).map(named), + unhandled: unhandled.rejections.map(String), + } satisfies Run) } catch (error) { hasPrimaryFailure = true throw error } finally { + warn.mockRestore() unhandled.cleanup() await cleanupOfflineOracle( [ @@ -311,128 +335,230 @@ async function checkInterruption({ } } -declareOracle({ check: [checkInterruption] }) +// --------------------------------------------------------------------------- +// Model and reach +// --------------------------------------------------------------------------- +// +// Each transaction owes its provider outcome; the interruption decides which +// laws apply. Where a transaction's interruption landed is read from the +// trace: whether it reached the provider, and in which phase. -/** - * Fixed histories designed to reach each interruption landing deterministically. - * Each named history specifies: a specific turn, outcomes, and transaction count - * that guarantee reaching a particular landing (issued, queued, or refused) with a - * specific interruption type. - * - * Landings: - * - "issued": provider call starts before interruption (phase before) - * - "queued": provider call starts after interruption (phase fenced or after) - * - "refused": outbox write never starts; admission is denied after interruption; - * commit fails with NonRetriableError - * - * For "refused" to occur with leadership loss or disposal, the outbox write must - * attempt to start after the interruption is already in place (during fenced phase). - * By using multiple transactions and appropriate turn timing, we ensure the later - * transaction's outbox write attempt reaches the fenced phase, where it is denied. - */ -const fixedLandings: Array<{ - name: string - history: History - expectedLanding: Landing -}> = [ - // Leadership landings - { - name: `leadership issued`, - history: { outcomes: [`fulfil`], interruption: `leadership`, turn: 50 }, - expectedLanding: `issued`, - }, - { - name: `leadership queued`, - history: { outcomes: [`fulfil`], interruption: `leadership`, turn: 1 }, - expectedLanding: `queued`, - }, - // For refused: two transactions, interrupt early so second one's outbox write - // reaches the fenced phase, and its outcome is reject so commit fails. - { - name: `leadership refused`, - history: { - outcomes: [`fulfil`, `reject`], - interruption: `leadership`, - turn: 0, - }, - expectedLanding: `refused`, - }, - // Offline landings (cannot be refused: going offline does not fence admission) - { - name: `offline issued`, - history: { outcomes: [`fulfil`], interruption: `offline`, turn: 50 }, - expectedLanding: `issued`, - }, - { - name: `offline queued`, - history: { outcomes: [`fulfil`], interruption: `offline`, turn: 1 }, - expectedLanding: `queued`, - }, - // Dispose landings - { - name: `dispose issued`, - history: { outcomes: [`fulfil`], interruption: `dispose`, turn: 50 }, - expectedLanding: `issued`, - }, - { - name: `dispose queued`, - history: { outcomes: [`fulfil`], interruption: `dispose`, turn: 1 }, - expectedLanding: `queued`, - }, - // For refused: two transactions, second one's outbox write reaches fenced phase. - { - name: `dispose refused`, - history: { - outcomes: [`fulfil`, `reject`], - interruption: `dispose`, - turn: 0, - }, - expectedLanding: `refused`, - }, -] +function landings(run: Run): Array { + return run.commits.map(({ id, result }) => { + const call = run.calls.find((entry) => entry.id === id) + if (!call && result.kind === `non-retriable`) return `refused` + return call?.phase === `before` ? `issued` : `queued` + }) +} -/** - * Deterministic test: each fixed history must reach its expected landing every time. - * This proves that each landing is reachable and that the oracle observes them correctly. - * A mutant that removes refusal paths or makes landings unreachable must fail here. - */ -it.each(fixedLandings)( - `landing $name is reachable: reaches $expectedLanding`, - async ({ history: fixedHistory, expectedLanding, name }) => { - const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) - try { - const landings = await checkInterruption(fixedHistory) - // For "refused" cases (which use two transactions), check the second transaction. - // For others, check the first. - const transactionIndex = name.includes(`refused`) ? 1 : 0 - expect(landings[transactionIndex], `${name} landing`).toBe( - expectedLanding, - ) - } finally { - warn.mockRestore() - } - }, - 60_000, +const landingStates = ([`leadership`, `offline`, `dispose`] as const).flatMap( + (interruption) => + (interruption === `offline` + ? ([`issued`, `queued`] as const) + : ([`refused`, `issued`, `queued`] as const) + ).map((landing) => `${interruption} ${landing}`), ) -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), +function interruptionModel({ outcomes }: History) { + return { outcomes } +} + +// --------------------------------------------------------------------------- +// Check +// --------------------------------------------------------------------------- + +function checkInterruption( + { interruption }: History, + trace: ReadonlyArray, + { outcomes }: ReturnType, +) { + const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as + Run | undefined + if (!run) return violation(`run`, `the driver recorded no run`, `trace`) + if (run.calls.some((call) => call.phase === `fenced`)) + return violation( + `run`, + `a provider call started between the interruption and recovery: ${JSON.stringify(run.calls)}`, + `fencing`, + ) + if (interruption !== `offline` && run.admissions.includes(`fenced`)) + return violation( + `run`, + `an outbox admission started after the interruption: ${JSON.stringify(run.admissions)}`, + `admission`, + ) + const calledIds = run.calls.map((call) => call.id) + if (new Set(calledIds).size !== calledIds.length) + return violation( + `run`, + `an ID reached the provider twice: ${JSON.stringify(calledIds)}`, + `once`, + ) + for (const [index, { id, result }] of run.commits.entries()) { + const called = calledIds.includes(id) + if (!called && result.kind === `non-retriable`) continue + if (!called) + return violation( + `run`, + `admitted ${id} never reached the provider; its commit is ${JSON.stringify(result)}`, + `settlement`, ) - } finally { - warn.mockRestore() + if (result.kind === `pending`) { + if (interruption !== `dispose`) + return violation( + `run`, + `${id} is still pending after recovery from ${interruption}`, + `settlement`, + ) + continue } + if (outcomes[index] === `fulfil`) { + if (result.kind !== `fulfilled`) + return violation( + `run`, + `${id} owed fulfilment, got ${JSON.stringify(result)}`, + `settlement`, + ) + if (result.storedAtFulfil.some((key) => key.includes(id))) + return violation( + `run`, + `${id} fulfilled while its row was still stored`, + `durability`, + ) + } else if ( + result.kind !== `non-retriable` || + result.message !== `provider rejected ${id}` + ) + return violation( + `run`, + `${id} owed its provider rejection, got ${JSON.stringify(result)}`, + `settlement`, + ) + } + const order = run.commits + .map(({ id }) => id) + .filter((id) => calledIds.includes(id)) + return ( + differs( + `run`, + `once`, + calledIds, + order, + `provider calls do not follow commit order`, + ) ?? + differs( + `run`, + `durability`, + run.outbox, + [], + `the outbox still holds rows after recovery`, + ) ?? + differs( + `run`, + `unhandled`, + run.unhandled, + [], + `a rejection escaped as an unhandled rejection`, + ) ?? + pass + ) +} + +// --------------------------------------------------------------------------- +// Property +// --------------------------------------------------------------------------- + +// Fixed histories that reach each landing: a late interruption lands after +// the provider call (`issued`), an early one before it (`queued`), and with +// two transactions an immediate one stops the second admission (`refused`). +const fixedLanding = ( + name: string, + interruption: Interruption, + outcomes: Array, + turn: number, +) => ({ + name, + enters: [name], + history: { outcomes, interruption, turn, schedule: null } satisfies History, +}) + +oracleProperty({ + id: `offline.interruption-settlement`, + law: { + statement: `Leadership loss, going offline, or disposal at any point of the commit pipeline keeps each offline transaction's admission, fencing, exactly-once provider call in commit order, settlement with its provider outcome, and durable ownership, with no unhandled rejection.`, + authority: `the offline-transactions README, and the settlement and leadership oracles`, + limits: `Disjoint keys (sibling rollback is the settlement oracle's); one to three transactions; fake storage over one store; no overlapping old and new executors, since exactly-once execution across independent leaders is not promised.`, + owner: `Offline execution`, }, - 120_000, -) + histories: { + arbitrary: history, + measured: landingStates, + // Half of the generated histories are immediate and half scheduled, so + // each timing keeps the 60 runs the immediate-only oracle had. + runs: 120, + fixedSeeds: [20251103], + fixed: [ + fixedLanding(`leadership issued`, `leadership`, [`fulfil`], 40), + fixedLanding(`leadership queued`, `leadership`, [`fulfil`], 1), + fixedLanding(`leadership refused`, `leadership`, [`fulfil`, `reject`], 0), + fixedLanding(`offline issued`, `offline`, [`fulfil`], 40), + fixedLanding(`offline queued`, `offline`, [`fulfil`], 1), + fixedLanding(`dispose issued`, `dispose`, [`fulfil`], 40), + fixedLanding(`dispose queued`, `dispose`, [`fulfil`], 1), + fixedLanding(`dispose refused`, `dispose`, [`fulfil`, `reject`], 0), + ], + }, + model: interruptionModel, + reached: ({ interruption }, trace) => { + const run = trace.find(({ checkpoint }) => checkpoint === `run`)?.value as + Run | undefined + return run + ? [ + ...new Set( + landings(run).map((landing) => `${interruption} ${landing}`), + ), + ] + : [] + }, + driver: driveInterruption, + check: checkInterruption, + calibrations: [ + { + name: `a provider call during the interruption`, + law: `fencing`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `run` && (entry.value as Run).calls.length + ? { + ...entry, + value: { + ...(entry.value as Run), + calls: (entry.value as Run).calls.map((call, index) => + index === 0 ? { ...call, phase: `fenced` } : call, + ), + }, + } + : entry, + ), + }, + { + name: `a repeated provider call`, + law: `once`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `run` && (entry.value as Run).calls.length + ? { + ...entry, + value: { + ...(entry.value as Run), + calls: [ + ...(entry.value as Run).calls, + (entry.value as Run).calls[0]!, + ], + }, + } + : entry, + ), + }, + ], +}) diff --git a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts index fb13405d1b..f407a581e4 100644 --- a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts @@ -1,10 +1,14 @@ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { OutboxManager } from '../src/outbox/OutboxManager' import { OfflineExecutor } from '../src/OfflineExecutor' import { TransactionExecutor } from '../src/executor/TransactionExecutor' -import { oracleReplayTest } from '../../db/tests/oracle-config' +import { + oracleReplayTest, + oracleRoute, + oracleRouteOf, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { FakeStorageAdapter, @@ -13,8 +17,12 @@ import { turn, } from './harness' import { storedTransaction } from './leadership-replay-support' -import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import { + atOracleCheckpoint, + cleanupOfflineOracle, + useControlledClock, +} from './oracle-lifecycle' +import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' /** @@ -57,8 +65,12 @@ import type { OfflineTransaction } from '../src/types' * unchanged. Gates control causal events. Named checkpoints compare provider * calls and idempotency keys, outbox contents, scheduler counts, leadership, * retry records, caller promise settlement, optimistic rows, and cleanup. + * Each test runs on a controlled clock (timers and `Date` virtual, macrotask + * turns real; `useControlledClock`), so a checkpoint that a retry backoff or + * a stuck replay would end fails at its virtual bound at once, and timers + * that never go quiet fail under law `bounded clock`. * - * Reach and controls: pinned histories force each lifecycle boundary and both + * Reach and controls: fixed histories force each lifecycle boundary and both * mixed-deletion positions. Fixed and random campaigns cover legal adjacent * histories and support seed/path replay. Delayed-read, stale-admission, and * mixed-removal witnesses reject the recorded pre-fix behaviors. The action @@ -73,6 +85,14 @@ import type { OfflineTransaction } from '../src/types' * storage engine. Exactly-once network execution across independent leaders is * not promised. Provider side effects after an issued call remain provider-owned. */ +let releaseClock: (() => void) | undefined +beforeEach(() => { + releaseClock = useControlledClock() +}) +afterEach(() => { + releaseClock?.() +}) + type ReplayLedgerEvent = | { type: `stored`; id: string } | { type: `read-captured`; ids: Array } @@ -112,8 +132,8 @@ function foldReplayLedger(events: ReadonlyArray): { // pnpm --dir packages/offline-transactions exec vitest run \ // tests/leadership-replay-oracle.property.test.ts // -// Grammar controls: each fixed lane enumerates the named causal boundary, and -// the seedless lane samples adjacent values with the same property/checkpoint. +// Grammar controls: each fixed campaign enumerates the named causal boundary, and +// the random campaign samples adjacent values with the same property/checkpoint. // Startup count 1–4 distinguishes empty peer work from multiple retained IDs; // the four disposal cuts distinguish construction, election, read, and hook. // Serial count 2–5 supplies an issued call plus peers; loss/disposal and @@ -121,11 +141,11 @@ function foldReplayLedger(events: ReadonlyArray): { // the payload checks that a surviving row is replayed unchanged. Repeated // reports range 0–5 before and after settlement; separate turns and a false // then true report distinguish coalescing from leadership reacquisition. The -// pinned one-report example reconstructs that overlap. Stale reads remove -// 1–5 of 2–6 stored IDs (clamped to leave a peer); the pinned 3/1 example +// fixed one-report example reconstructs that overlap. Stale reads remove +// 1–5 of 2–6 stored IDs (clamped to leave a peer); the fixed 3/1 example // reconstructs durable removal after capture. Delayed reads vary 1–4 peers, // 1–3 scans, and removal before/after acknowledgment; both one-scan cuts are -// pinned. Storage failure varies keys/get and 1–3 retained records. Removing +// fixed. Storage failure varies keys/get and 1–3 retained records. Removing // any of these axes loses the stated cut, multiplicity, or preservation check. // Delivery before capture and acknowledgment before provider completion are // causally invalid histories, so neither is generated. Zero stored work and @@ -162,8 +182,18 @@ const storageFailureOracle = readOfflineOracleConfig({ }) // The stale-read campaign compares each delivered read with the ledger fold, -// the model. The append-only judgment pins the fold's own result. -declareOracle({ check: [checkStaleRead], model: [foldReplayLedger] }) +// the model. The append-only judgment fixes the fold's own result. +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/OfflineExecutor.ts#startOfflineExecutor`, + `src/OfflineExecutor.ts#OfflineExecutor.waitForInit`, + `src/OfflineExecutor.ts#OfflineExecutor.dispose`, + ], + check: [checkStaleRead], + model: [foldReplayLedger], +}) const startupCases = (seed: number | undefined) => [`construction`, `leadership`, `outbox read`, `retry hook`].map( @@ -173,12 +203,14 @@ const startupCases = (seed: number | undefined) => }), ) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of STARTUP_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of STARTUP_PROPERTY. async function checkStartup({ boundary, seed, }: ReturnType[number]): Promise { + const parameters = oracleCampaignOptions(startupOracle, seed) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(fc.integer({ min: 1, max: 4 }), async (count) => { const pending = gate() @@ -284,7 +316,7 @@ async function checkStartup({ ) } }), - oracleLaneOptions(startupOracle, seed), + parameters, ) } @@ -306,19 +338,20 @@ const serialWorkCases = (seed: number | undefined) => })), ) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of SERIAL_WORK_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of SERIAL_WORK_PROPERTY. async function checkSerialWork({ stop, boundary, seed, }: ReturnType[number]): Promise { + const campaignParameters = oracleCampaignOptions(serialWorkOracle, seed) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.integer({ min: 2, max: 5 }), fc.string({ maxLength: 20 }), async (count, payload) => { - vi.useFakeTimers() const executions = vi.spyOn(TransactionExecutor.prototype, `executeAll`) const entered = gate() const provider = gate() @@ -471,7 +504,6 @@ async function checkSerialWork({ provider.resolve() acknowledgment.resolve() executions.mockRestore() - vi.useRealTimers() await cleanupOfflineOracle( [ () => env.executor.dispose(), @@ -484,7 +516,7 @@ async function checkSerialWork({ } }, ), - oracleLaneOptions(serialWorkOracle, seed), + campaignParameters, ) } @@ -503,12 +535,27 @@ oracleReplayTest(it, SERIAL_WORK_PROPERTY).each(serialWorkCases(undefined))( const reportCases = (seed: number | undefined) => [`provider`, `acknowledgment`].map((boundary) => ({ boundary, seed })) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of REPORT_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of REPORT_PROPERTY. async function checkReport({ boundary, seed, }: ReturnType[number]): Promise { + const runParameters = { + ...oracleCampaignOptions(leadershipReportOracle, seed), + examples: [ + [ + { + pendingReports: 1, + settledReports: 1, + separateTurns: true, + regainLeadership: true, + value: `payload`, + }, + ], + ], + } + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -672,20 +719,7 @@ async function checkReport({ } }, ), - { - ...oracleLaneOptions(leadershipReportOracle, seed), - examples: [ - [ - { - pendingReports: 1, - settledReports: 1, - separateTurns: true, - regainLeadership: true, - value: `payload`, - }, - ], - ], - }, + runParameters, ) } @@ -698,9 +732,14 @@ oracleReplayTest(it, REPORT_PROPERTY).each(reportCases(undefined))( checkReport, ) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of STALE_READ_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of STALE_READ_PROPERTY. async function checkStaleRead(seed: number | undefined): Promise { + const parameters = { + ...oracleCampaignOptions(staleReadOracle, seed), + examples: [[{ count: 3, removedPrefix: 1 }]], + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -774,10 +813,7 @@ async function checkStaleRead(seed: number | undefined): Promise { } }, ), - { - ...oracleLaneOptions(staleReadOracle, seed), - examples: [[{ count: 3, removedPrefix: 1 }]], - }, + parameters, ) } @@ -791,6 +827,7 @@ oracleReplayTest(it, STALE_READ_PROPERTY).each([undefined])( ) it(`append-only replay judgment rejects stale admission after durable removal`, () => { + oracleRoute(`case`) const ledger: Array = [ { type: `stored`, id: `removed` }, { type: `stored`, id: `peer` }, @@ -807,9 +844,31 @@ it(`append-only replay judgment rejects stale admission after durable removal`, // A replay scan can capture A, then block on another storage read until A's // successful deletion. Pending-only dedupe no longer remembers A at admission. // Peers admitted only through this scan must still execute exactly once. -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of DELAYED_READ_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of DELAYED_READ_PROPERTY. async function checkDelayedRead(seed: number | undefined): Promise { + const parameters = { + ...oracleCampaignOptions(delayedReadOracle, seed), + examples: [ + [ + { + peers: 1, + scans: 1, + afterAcknowledgment: true, + payload: `witness`, + }, + ], + [ + { + peers: 1, + scans: 1, + afterAcknowledgment: false, + payload: `control`, + }, + ], + ], + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -946,27 +1005,7 @@ async function checkDelayedRead(seed: number | undefined): Promise { } }, ), - { - ...oracleLaneOptions(delayedReadOracle, seed), - examples: [ - [ - { - peers: 1, - scans: 1, - afterAcknowledgment: true, - payload: `witness`, - }, - ], - [ - { - peers: 1, - scans: 1, - afterAcknowledgment: false, - payload: `control`, - }, - ], - ], - }, + parameters, ) } @@ -982,12 +1021,14 @@ oracleReplayTest(it, DELAYED_READ_PROPERTY).each([undefined])( const storageFailureCases = (seed: number | undefined) => [`keys`, `get`].map((operation) => ({ operation, seed })) -// The fixed lane stands aside during any path replay. The random lane runs in -// ordinary and seed-only runs, and it is the replay of STORAGE_FAILURE_PROPERTY. +// The fixed campaign stands aside during any path replay. The random campaign runs in +// ordinary and reseeded campaigns, and it is the replay of STORAGE_FAILURE_PROPERTY. async function checkStorageFailure({ operation, seed, }: ReturnType[number]): Promise { + const parameters = oracleCampaignOptions(storageFailureOracle, seed) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(fc.integer({ min: 1, max: 3 }), async (count) => { const error = new Error(`stored work unavailable`) @@ -1037,7 +1078,7 @@ async function checkStorageFailure({ ) } }), - oracleLaneOptions(storageFailureOracle, seed), + parameters, ) } diff --git a/packages/offline-transactions/tests/leadership-replay.test.ts b/packages/offline-transactions/tests/leadership-replay.test.ts index 22081f396c..426aa16221 100644 --- a/packages/offline-transactions/tests/leadership-replay.test.ts +++ b/packages/offline-transactions/tests/leadership-replay.test.ts @@ -1,5 +1,5 @@ // Leadership replay cases whose expectations are written by hand: a fixed -// outbox restart, pinned admission and retry-hook histories, stale reads, +// outbox restart, fixed admission and retry-hook histories, stale reads, // and failed deletions. The leadership and replay laws they illustrate are // owned by leadership-replay-oracle.property.test.ts. import { expect, it, vi } from 'vitest' diff --git a/packages/offline-transactions/tests/oracle-config.ts b/packages/offline-transactions/tests/oracle-config.ts index 47ff04632a..09d7efeec1 100644 --- a/packages/offline-transactions/tests/oracle-config.ts +++ b/packages/offline-transactions/tests/oracle-config.ts @@ -1,4 +1,10 @@ -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' type OfflineOracleConfigOptions = { /** A property registered in packages/db/tests/oracle-config.ts. */ @@ -11,7 +17,7 @@ type OfflineOracleConfigOptions = { /** * The budgets of one offline oracle property. `runs` sizes its fixed-seed - * lane. `random` holds the shared property options for its random lane: a seed + * campaign. `random` holds the shared property options for its random campaign: a seed * alone reseeds it, and a path replay of `property` runs its recorded history * once. */ @@ -23,7 +29,7 @@ export function readOfflineOracleConfig({ runs: number random: ReturnType } { - // Both lanes share one budget: the scaled base plus the reserved fixed + // Both campaigns share one run budget: the scaled base plus the reserved fixed // examples. A path replay keeps its single run. const runs = oracleRuns(defaultRuns) + reservedRuns const random = oraclePropertyOptions(defaultRuns, property) @@ -34,17 +40,26 @@ export function readOfflineOracleConfig({ } /** - * A lane that passes fixed `examples` keeps them during a path replay. + * A campaign that passes fixed `examples` keeps them during a path replay. * fast-check counts the examples in the recorded path, so a replay without * them would select a different value. * - * The fast-check options for one lane. An `undefined` seed selects the random - * lane. A number selects the fixed lane, which keeps its ordinary budget. + * The fast-check options for one campaign, called in the test that runs it. + * An `undefined` seed selects the random campaign. A number selects the + * fixed-seed campaign, which declares its seed and keeps its ordinary run + * budget. The test's route for the gap report is the route of these + * options. */ -export function oracleLaneOptions( +export function oracleCampaignOptions( config: ReturnType, seed: number | undefined, ): - ReturnType | { numRuns: number; seed: number } { - return seed === undefined ? config.random : { numRuns: config.runs, seed } + | ReturnType + | ReturnType> { + const parameters = + seed === undefined + ? config.random + : oracleFixedSeed(seed, { numRuns: config.runs }) + oracleRoute(oracleRouteOf(parameters)) + return parameters } diff --git a/packages/offline-transactions/tests/oracle-lifecycle.ts b/packages/offline-transactions/tests/oracle-lifecycle.ts index a96671db71..1c219a64d1 100644 --- a/packages/offline-transactions/tests/oracle-lifecycle.ts +++ b/packages/offline-transactions/tests/oracle-lifecycle.ts @@ -1,3 +1,14 @@ +import { vi } from 'vitest' +import { useOracleClock } from '../../db/tests/oracle-property' + +// The real macrotask yield, taken before any test fakes the clock. +const realImmediate = globalThis.setImmediate +// How far a checkpoint moves a controlled clock between real turns. +const VIRTUAL_STEP = 5 +// Whether `useControlledClock` runs the clock. A test that fakes timers and +// moves them itself keeps checkpoints on its own clock. +let controlled = false + /** * Bound offline-oracle observation and exhaust test-owned cleanup. * @@ -12,6 +23,7 @@ export async function atOracleCheckpoint( label: string, timeout = 1000, ): Promise { + if (controlled) return atVirtualCheckpoint(promise, label, timeout) let timer: ReturnType | undefined try { return await Promise.race([ @@ -29,6 +41,53 @@ export async function atOracleCheckpoint( } } +/** + * A checkpoint on the controlled clock (`useControlledClock`). The bound is + * virtual: between real macrotask turns, which let in-memory work finish, + * the clock moves `VIRTUAL_STEP` ms, firing the timers that come due; the + * checkpoint fails once the clock has moved `timeout` ms. A wait that real + * time would end only after a backoff or a timeout therefore fails at once, + * and a loaded machine cannot slow a correct history past its bound. + */ +async function atVirtualCheckpoint( + promise: Promise, + label: string, + timeout: number, +): Promise { + let outcome: { value: T } | { error: unknown } | undefined + promise.then( + (value) => (outcome = { value }), + (error: unknown) => (outcome = { error }), + ) + for (let elapsed = 0; ; elapsed += VIRTUAL_STEP) { + await new Promise((resolve) => realImmediate(resolve)) + if (outcome) { + if (`error` in outcome) throw outcome.error + return outcome.value + } + if (elapsed + VIRTUAL_STEP >= timeout) + throw new Error(`Offline oracle checkpoint timed out: ${label}`) + await vi.advanceTimersByTimeAsync(VIRTUAL_STEP) + } +} + +/** + * Runs a history on a controlled clock (the framework's `useOracleClock`): + * timers and `Date` are virtual, and macrotask turns (`setImmediate`) stay + * real, so in-memory work finishes between clock steps. Returns the release, + * which restores real timers and fails the test if its timers never went + * quiet. + */ +export function useControlledClock(): () => void { + // The framework's clock, which fails a test whose timers never go quiet. + const clock = useOracleClock() + controlled = true + return () => { + controlled = false + clock.release() + } +} + /** * Record unhandled process rejections until `cleanup` runs. Call `cleanup` in * a `finally` block. diff --git a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts index eec9fd11d7..1d2c93c974 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -6,14 +6,17 @@ import { OutboxManager } from '../src/outbox/OutboxManager' import { TransactionSerializer } from '../src/outbox/TransactionSerializer' import { runOracleCampaign } from '../../db/tests/oracle-campaign' import { + oracleFixedSeed, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { cleanupOfflineOracle } from './oracle-lifecycle' import { FakeStorageAdapter } from './harness' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' /** @@ -48,8 +51,8 @@ import type { OfflineTransaction } from '../src/types' * process or restarting an existing Collection's sync run. Seed/path replay is * oracle replay of a recorded failure. * - * Reach and controls: pinned histories force every edit kind and hostile marker. - * Fixed and random lanes support seed/path replay. Encoder and decoder faults + * Reach and controls: fixed histories force every edit kind and hostile marker. + * Fixed and random campaigns support seed/path replay. Encoder and decoder faults * inject Date/string confusion, wrong registry, omitted changes, and unknown * versions. Malformed generated markers are rejected before durable data can * be replaced; the single-case Temporal failure paths are unit tests. @@ -385,7 +388,18 @@ async function checkRoundtrip( // The check runs one history through production and compares every checkpoint // with the semantic model above. Generated properties, fixed histories, and the // fault calibrations all reach it. -declareOracle({ check: [checkRoundtrip] }) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. +declareOracle({ + owns: [ + `src/outbox/TransactionSerializer.ts#TransactionSerializer.serialize`, + `src/outbox/TransactionSerializer.ts#TransactionSerializer.deserialize`, + `src/outbox/OutboxManager.ts#OutboxManager.add`, + `src/outbox/OutboxManager.ts#OutboxManager.getAll`, + `src/outbox/OutboxManager.ts#OutboxManager.get`, + ], + check: [checkRoundtrip], +}) const twin: Pair = { runtime: `2024-01-01T00:00:00.000Z`, @@ -393,6 +407,7 @@ const twin: Pair = { } it(`preserves marker-shaped user data through current wire encoding`, async () => { + oracleRoute(`case`) const runtime = { __type: `Temporal`, type: `Temporal.PlainDate`, @@ -422,6 +437,13 @@ it.each([`modified`, `original`, `changes`] as const)( }) const serializer = new TransactionSerializer({ rows: collection }) try { + const parameters = oracleFixedSeed(20260914, { + numRuns: 100, + examples: [undefined, null, 1, `text`, false, []].map( + (value): [typeof value, Array] => [value, []], + ), + }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property( fc.constantFrom(undefined, null, 1, `text`, false, []), @@ -454,20 +476,14 @@ it.each([`modified`, `original`, `changes`] as const)( ).toThrow(`Corrupted Object marker`) }, ), - { - seed: 20260914, - numRuns: 100, - examples: [undefined, null, 1, `text`, false, []].map( - (value): [typeof value, Array] => [value, []], - ), - }, + parameters, ) } finally { await collection.cleanup() } }, ) -const pinned: Array = [ +const fixedHistory: Array = [ { kind: `insert`, slot: 1, before: twin, after: { runtime: 0.5, wire: 0.5 } }, { kind: `insert`, slot: 0, before: twin, after: datePair(1704067200000) }, { kind: `update`, slot: 1, before: datePair(0), after: twin }, @@ -488,20 +504,20 @@ const pinned: Array = [ } }), ] -// Grammar controls: the pinned history is a legal ten-edit reconstruction of +// Grammar controls: the fixed history is a legal ten-edit reconstruction of // every edit kind, both registry slots, Date/string twins, marker escapes, and // an own __proto__ key. Dropping one of those axes loses a distinct wire or // application-restart observation. Generated trees add empty and nested arrays // and objects with depth two and at most three children or keys. Current records // admit zero edits, while compatibility records start at one. Cycles and malformed // markers are outside this valid-tree grammar and have rejection witnesses -// above. The generated edit bound includes the complete pinned history. A +// above. The generated edit bound includes the complete fixed history. A // never-escape rule fails its marker-shaped user object; an always-escape rule // fails its ordinary object with an own __proto__ key at the wire checkpoint. -// This package's test root is separate from core's named replay portfolio. +// This package's test root is separate from core's named fixed-history portfolio. // Keep a local replay entry point rather than importing files outside rootDir. // Each random campaign is its own replay property, because a recorded path -// belongs to one generator. Its fixed lane and its random lane share one +// belongs to one generator. Its fixed campaign and its random campaign share one // generator, budget, and assertion set. const MUTATION_WIRE_PROPERTY = `offline.serializer.mutation-wire` const DATE_MARKER_PROPERTY = `offline.serializer.date-marker` @@ -526,7 +542,7 @@ const mutationEdits = fc.array( before: tree(2), after: tree(2), }), - { maxLength: pinned.length }, + { maxLength: fixedHistory.length }, ) const mutationTime = fc.integer({ min: -2000000000000, @@ -538,10 +554,12 @@ const mutationMeaningProperty = (fault: Fault = `none`) => ) const checkMutationWire = async (seed: number | undefined): Promise => { - await fc.assert(mutationMeaningProperty(), { - ...oracleLaneOptions(mutationWireOracle, seed), - examples: [[pinned, 1704067200000]], - }) + const parameters = { + ...oracleCampaignOptions(mutationWireOracle, seed), + examples: [[fixedHistory, 1704067200000]], + } + oracleRoute(oracleRouteOf(parameters)) + await fc.assert(mutationMeaningProperty(), parameters) } oracleReplayTest(it).each([20260914])( @@ -556,6 +574,7 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY).each([undefined])( oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( `replays a shrunk mutation-wire failure at the same checkpoint`, async () => { + oracleRoute(`calibration`) // The fault drops a mutation field after the real encoder runs. It leaves // zero-edit histories green, so fast-check must find and shrink a generated // history that reaches the wire assertion. A seed alone reseeds this @@ -565,15 +584,15 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( // TANSTACK_DB_ORACLE_PROPERTY=offline.serializer.mutation-wire const property = mutationMeaningProperty(`omit-changes`) const replay = mutationWireOracle.random - const { replaySeed } = readOracleRunConfig() + const { campaignSeed } = readOracleRunConfig() const captured = await fc.check(property, { ...replay, - seed: replaySeed ?? 20260914, + seed: campaignSeed ?? 20260914, }) expect(captured.failed).toBe(true) expect(captured.errorInstance).toMatchObject({ name: `AssertionError` }) - expect(captured.error).toContain(`mutation wire checkpoint`) - if (replaySeed !== undefined) return + expect(String(captured.errorInstance)).toContain(`mutation wire checkpoint`) + if (campaignSeed !== undefined) return expect(captured.numShrinks).toBeGreaterThan(0) expect(captured.counterexamplePath).toMatch(/^\d+(?::\d+)*$/) @@ -586,7 +605,7 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( }) expect(original.failed).toBe(true) expect(original.errorInstance).toMatchObject({ name: `AssertionError` }) - expect(original.error).toContain(`mutation wire checkpoint`) + expect(String(original.errorInstance)).toContain(`mutation wire checkpoint`) const replayed = await fc.check(property, { seed: captured.seed, path: capturedPath, @@ -595,15 +614,16 @@ oracleReplayTest(it, MUTATION_WIRE_PROPERTY)( expect(replayed.failed).toBe(true) expect(replayed.counterexample).toEqual(captured.counterexample) expect(replayed.errorInstance).toMatchObject({ name: `AssertionError` }) - expect(replayed.error).toContain(`mutation wire checkpoint`) + expect(String(replayed.errorInstance)).toContain(`mutation wire checkpoint`) }, ) it(`retains the wire mismatch when cleanup also fails`, async () => { + oracleRoute(`calibration`) let failure: unknown try { await checkRoundtrip( - [pinned[0]!], + [fixedHistory[0]!], 0, `omit-changes`, `encoder`, @@ -624,6 +644,13 @@ it(`retains the wire mismatch when cleanup also fails`, async () => { }) const checkDateMarker = async (seed: number | undefined): Promise => { + const campaignParameters = { + ...oracleCampaignOptions(dateMarkerOracle, seed), + examples: [ + [[{ kind: `update`, slot: 0, before: datePair(0), after: twin }]], + ], + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.array( @@ -633,7 +660,7 @@ const checkDateMarker = async (seed: number | undefined): Promise => { before: leaf, after: leaf, }), - { minLength: 1, maxLength: pinned.length }, + { minLength: 1, maxLength: fixedHistory.length }, ), async (edits) => { // Old records had no object escape. Use only unambiguous legacy trees, @@ -655,12 +682,7 @@ const checkDateMarker = async (seed: number | undefined): Promise => { ) }, ), - { - ...oracleLaneOptions(dateMarkerOracle, seed), - examples: [ - [[{ kind: `update`, slot: 0, before: datePair(0), after: twin }]], - ], - }, + campaignParameters, ) } @@ -674,6 +696,11 @@ oracleReplayTest(it, DATE_MARKER_PROPERTY).each([undefined])( ) const checkVersionTwo = async (seed: number | undefined): Promise => { + const runParameters = { + ...oracleCampaignOptions(versionTwoOracle, seed), + examples: [[fixedHistory]], + } + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty( fc.array( @@ -683,14 +710,11 @@ const checkVersionTwo = async (seed: number | undefined): Promise => { before: tree(2), after: tree(2), }), - { minLength: 1, maxLength: pinned.length }, + { minLength: 1, maxLength: fixedHistory.length }, ), async (edits) => checkRoundtrip(edits, 0, `none`, `encoder`, false, true), ), - { - ...oracleLaneOptions(versionTwoOracle, seed), - examples: [[pinned]], - }, + runParameters, ) } @@ -721,6 +745,7 @@ it.each( )( `rejects the $fault $boundary mutant at its own boundary`, async ({ fault, boundary }) => { + oracleRoute(`calibration`) const decode = vi.spyOn(TransactionSerializer.prototype, `deserialize`) try { const expectedFailure = @@ -734,7 +759,7 @@ it.each( : `Unsupported transaction value encoding: 5`, } await expect( - checkRoundtrip(pinned, 0, fault, boundary), + checkRoundtrip(fixedHistory, 0, fault, boundary), ).rejects.toMatchObject(expectedFailure) expect(decode).toHaveBeenCalledTimes(boundary === `encoder` ? 0 : 1) } finally { diff --git a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts index 7dfe23d709..c68acd7793 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -1,11 +1,21 @@ import fc from 'fast-check' import { createCollection, createTransaction } from '@tanstack/db' -import { afterAll, describe, expect, it, vi } from 'vitest' +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest' import { NonRetriableError } from '../src/types' import { OutboxManager } from '../src/outbox/OutboxManager' import { oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' @@ -19,8 +29,9 @@ import { atOracleCheckpoint, captureUnhandledRejections, cleanupOfflineOracle, + useControlledClock, } from './oracle-lifecycle' -import { oracleLaneOptions, readOfflineOracleConfig } from './oracle-config' +import { oracleCampaignOptions, readOfflineOracleConfig } from './oracle-config' import type { TestItem } from './harness' import type { Transaction } from '@tanstack/db' import type { OfflineConfig } from '../src/types' @@ -65,7 +76,7 @@ import type { OfflineConfig } from '../src/types' * generated. When nobody reads an auto-commit's receipt, the Collection that * tracks the transaction owns its rejection; every generated transaction * writes to one. A caller may also register its completion waiter before the - * transaction is admitted. Pinned examples + * transaction is admitted. Fixed histories * reconstruct all-success, middle-failure, all-failure, and alternating * outcomes. Shared keys distinguish sibling rollback from disjoint-key * survival. Width distinguishes complete optimistic state for several rows @@ -74,6 +85,12 @@ import type { OfflineConfig } from '../src/types' * the marginal peer and width cases. Fresh production IDs exclude duplicate-ID * histories; an outcome-less provider call is not a completed history here. * + * Every history runs on a controlled clock: timers and `Date` are virtual, + * and macrotask turns stay real, so in-memory work finishes between clock + * steps. A checkpoint fails once virtual time passes its bound, so work left + * waiting on a retry backoff or a timer fails at once rather than after real + * seconds. + * * Gates expose each provider boundary. The real executor, Collection, and * outbox are the driver. At each held-provider checkpoint, the refinement * check compares exact calls, IDs, promise outcomes, durable outbox state, @@ -94,13 +111,13 @@ import type { OfflineConfig } from '../src/types' * crash windows around the phase write. The controlled provider and fake * storage establish this executor boundary, not real server acknowledgement * timing, native storage completion, or multiple-owner leadership. A normal - * run pairs fixed and seedless campaigns. + * run pairs fixed and random campaigns. * A permanent named mutation function failure uses a separate rejection-pending * marker. The original caller promise rejects with that failure. A storage * failure also rejects the executor batch promise and stops further work. An * offline executor restart removes a marked outbox entry without another named * mutation function call or restoration of optimistic state. - * Direct executor checks pin the storage error, FIFO peer hold, and refusal of + * Direct executor checks cover the storage error, FIFO peer hold, and refusal of * further attempts. Outbox replay can invoke the named mutation function after * a crash if the marker write failed. * The `shouldRetry` option changes only the decision after a named mutation @@ -156,6 +173,15 @@ import type { OfflineConfig } from '../src/types' * file stand aside. */ +// Every history runs on the controlled clock (see the opening prose). +let releaseClock: (() => void) | undefined +beforeEach(() => { + releaseClock = useControlledClock() +}) +afterEach(() => { + releaseClock?.() +}) + // Each campaign is its own replay property, because a recorded path belongs to // one generator. A path replay of a campaign's property runs only that campaign. const SETTLEMENT_PROPERTY = `offline.settlement.transaction` @@ -397,6 +423,232 @@ const settlementTransaction = fc ) const checkSettlement = async (seed: number | undefined): Promise => { + const parameters = { + ...oracleCampaignOptions(settlementOracle, seed), + examples: [ + [ + { + sharedKeys: true, + width: 1, + transactions: [ + { + succeeds: true, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: true, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + ], + }, + ], + [ + { + sharedKeys: false, + width: 2, + transactions: [ + { + succeeds: true, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: false, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: true, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + ], + }, + ], + [ + { + sharedKeys: true, + width: 2, + transactions: [ + { + succeeds: false, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: false, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: false, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + ], + }, + ], + [ + { + sharedKeys: true, + width: 1, + transactions: [ + { + succeeds: false, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: true, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: false, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + ], + }, + ], + [ + { + sharedKeys: true, + width: 1, + transactions: [ + { + succeeds: false, + commit: `auto`, + readReceipt: false, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: false, + }, + { + succeeds: true, + commit: `manual`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: true, + }, + { + succeeds: false, + commit: `auto`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: false, + }, + ], + }, + ], + // A failed auto-commit with the default option, and a failed + // action, settle through when('settled') only. + [ + { + sharedKeys: false, + width: 1, + transactions: [ + { + succeeds: false, + commit: `default`, + readReceipt: false, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: false, + }, + { + succeeds: false, + commit: `action`, + readReceipt: true, + emptyBefore: false, + rolledBackBefore: false, + waitBefore: false, + }, + ], + }, + ], + // Empty transactions on every commit path complete without the + // provider, before and between real ones. + [ + { + sharedKeys: false, + width: 1, + transactions: [ + { + succeeds: true, + commit: `manual`, + readReceipt: true, + emptyBefore: true, + rolledBackBefore: true, + waitBefore: false, + }, + { + succeeds: false, + commit: `auto`, + readReceipt: false, + emptyBefore: true, + rolledBackBefore: true, + waitBefore: false, + }, + { + succeeds: true, + commit: `default`, + readReceipt: true, + emptyBefore: true, + rolledBackBefore: true, + waitBefore: false, + }, + { + succeeds: true, + commit: `action`, + readReceipt: true, + emptyBefore: true, + rolledBackBefore: true, + waitBefore: false, + }, + ], + }, + ], + ], + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( // One record per transaction, so every commit path and an unread @@ -746,231 +998,7 @@ const checkSettlement = async (seed: number | undefined): Promise => { } }, ), - { - ...oracleLaneOptions(settlementOracle, seed), - examples: [ - [ - { - sharedKeys: true, - width: 1, - transactions: [ - { - succeeds: true, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: true, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - ], - }, - ], - [ - { - sharedKeys: false, - width: 2, - transactions: [ - { - succeeds: true, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: false, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: true, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - ], - }, - ], - [ - { - sharedKeys: true, - width: 2, - transactions: [ - { - succeeds: false, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: false, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: false, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - ], - }, - ], - [ - { - sharedKeys: true, - width: 1, - transactions: [ - { - succeeds: false, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: true, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: false, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - ], - }, - ], - [ - { - sharedKeys: true, - width: 1, - transactions: [ - { - succeeds: false, - commit: `auto`, - readReceipt: false, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: false, - }, - { - succeeds: true, - commit: `manual`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: true, - }, - { - succeeds: false, - commit: `auto`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: false, - }, - ], - }, - ], - // A failed auto-commit with the default option, and a failed - // action, settle through when('settled') only. - [ - { - sharedKeys: false, - width: 1, - transactions: [ - { - succeeds: false, - commit: `default`, - readReceipt: false, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: false, - }, - { - succeeds: false, - commit: `action`, - readReceipt: true, - emptyBefore: false, - rolledBackBefore: false, - waitBefore: false, - }, - ], - }, - ], - // Empty transactions on every commit path complete without the - // provider, before and between real ones. - [ - { - sharedKeys: false, - width: 1, - transactions: [ - { - succeeds: true, - commit: `manual`, - readReceipt: true, - emptyBefore: true, - rolledBackBefore: true, - waitBefore: false, - }, - { - succeeds: false, - commit: `auto`, - readReceipt: false, - emptyBefore: true, - rolledBackBefore: true, - waitBefore: false, - }, - { - succeeds: true, - commit: `default`, - readReceipt: true, - emptyBefore: true, - rolledBackBefore: true, - waitBefore: false, - }, - { - succeeds: true, - commit: `action`, - readReceipt: true, - emptyBefore: true, - rolledBackBefore: true, - waitBefore: false, - }, - ], - }, - ], - ], - }, + parameters, ) } @@ -1033,6 +1061,8 @@ describe(`restored snapshots and independent outbox settlement`, () => { it.each(restoredHistories)( `restores whole snapshots through independent outcomes: %j`, async (history) => { + // An enumerated history: a reproducible route for the gap report. + oracleRoute(`case`) reach.beginHistory() const rows = restoredRows(history) const storage = new FakeStorageAdapter() @@ -1231,6 +1261,7 @@ it.each([ `early-peer`, `missing-row`, ] as const)(`rejects a wrong restored observation: %s`, (fault) => { + oracleRoute(`calibration`) const history: RestoredHistory = { layout: `shared`, width: 2, @@ -1368,6 +1399,11 @@ function retryDecisionProviderError(kind: RetryErrorKind): Error { // every fixed cell. A seed-and-path replay runs only its requested history. async function checkRetryDecision(seed: number | undefined): Promise { const reachedCases = new Set() + const campaignParameters = { + ...oracleCampaignOptions(retryDecisionOracle, seed), + examples: retryDecisionExamples, + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty( fc.constantFrom(...retryDecisionCases), @@ -1611,10 +1647,7 @@ async function checkRetryDecision(seed: number | undefined): Promise { } }, ), - { - ...oracleLaneOptions(retryDecisionOracle, seed), - examples: retryDecisionExamples, - }, + campaignParameters, ) if (!oracleReplayRequested()) expect([...reachedCases].sort()).toEqual( @@ -1681,6 +1714,7 @@ it.each([ ] as const)( `settles a started peer across %s hook failure and %s write`, async (failureKind, peerWriteCase) => { + oracleRoute(`case`) const providerError = new Error(`HTTP 401 Unauthorized`) const hookError = new Error(`retry decision failed`) const storageError = new Error(`peer outbox write failed`) @@ -1984,6 +2018,11 @@ it.each([ // The fixture does not generate a duplicate ID or a successful retry-record // write, because neither has this failure premise. async function checkRetryRecordFailure(seed: number | undefined) { + const runParameters = { + ...oracleCampaignOptions(retryRecordOracle, seed), + examples: [[2], [5]], + } + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty(fc.integer({ min: 2, max: 5 }), async (count) => { const storageError = new Error(`retry record unavailable`) @@ -2098,10 +2137,7 @@ async function checkRetryRecordFailure(seed: number | undefined) { ) } }), - { - ...oracleLaneOptions(retryRecordOracle, seed), - examples: [[2], [5]], - }, + runParameters, ) } @@ -2115,10 +2151,15 @@ oracleReplayTest(it, RETRY_RECORD_PROPERTY).each([undefined])( ) // The failed durable write can be first, middle, or last among three distinct -// IDs. All positions are pinned, so the conditional failure premise is reached +// IDs. All positions are fixed, so the conditional failure premise is reached // at each boundary. Removing the two admitted peers would hide accidental // global rejection. A duplicate ID is outside this legal admission grammar. async function checkDurableAdmissionFailure(seed: number | undefined) { + const parameters = { + ...oracleCampaignOptions(admissionOracle, seed), + examples: [[0], [1], [2]], + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(fc.integer({ min: 0, max: 2 }), async (failedIndex) => { const storageError = new Error(`admission unavailable`) @@ -2210,10 +2251,7 @@ async function checkDurableAdmissionFailure(seed: number | undefined) { ) } }), - { - ...oracleLaneOptions(admissionOracle, seed), - examples: [[0], [1], [2]], - }, + parameters, ) } @@ -2231,7 +2269,18 @@ oracleReplayTest(it, ADMISSION_PROPERTY).each([undefined])( // retry decision table and FIFO local rows, and the visibility and provider // eligibility of a started peer. Fixed scenarios whose expectations are // written by hand are not oracle tests; they live in transaction-settlement.test.ts. +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [ + `src/OfflineExecutor.ts#startOfflineExecutor`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineAction`, + `src/OfflineExecutor.ts#OfflineExecutor.waitForTransactionCompletion`, + `src/OfflineExecutor.ts#OfflineExecutor.rejectTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.peekOutbox`, + `src/OfflineExecutor.ts#OfflineExecutor.dispose`, + ], check: [ checkSettlement, checkRetryDecision, diff --git a/packages/offline-transactions/vite.config.ts b/packages/offline-transactions/vite.config.ts index 3dc3529122..7771626fa4 100644 --- a/packages/offline-transactions/vite.config.ts +++ b/packages/offline-transactions/vite.config.ts @@ -1,8 +1,11 @@ import { defineConfig, mergeConfig } from 'vitest/config' import { tanstackViteConfig } from '@tanstack/vite-config' +import { stripOracleProbes } from '../../scripts/strip-oracle-probes.mjs' import packageJson from './package.json' const config = defineConfig({ + // The published build carries no oracle state probes. + plugins: [stripOracleProbes()], test: { name: packageJson.name, dir: `./tests`, diff --git a/packages/powersync-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts b/packages/powersync-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts index b24358385d..5aa52a2e0a 100644 --- a/packages/powersync-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts +++ b/packages/powersync-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { describe, it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -47,6 +50,7 @@ async function campaign(options: fc.Parameters<[unknown]>) { const reach = createOracleReach(property, reachStatesFor(driver), { minimum: 5, }) + oracleRoute(oracleRouteOf(options)) await fc.assert(eagerLiveSourceProperty(driver, reach), options) return reach } @@ -55,7 +59,9 @@ describe(`eager live-source conformance: PowerSync`, () => { oracleReplayTest(it)( `conforms on a fixed-seed campaign`, async () => { - const reach = await campaign({ seed: 2118, numRuns: oracleRuns(runs) }) + const reach = await campaign( + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), + ) reach.assertReached() }, campaignTimeoutMs, @@ -72,6 +78,7 @@ describe(`eager live-source conformance: PowerSync`, () => { }) declareOracle({ + owns: [`src/powersync.ts#powerSyncCollectionOptions`], check: [checkEagerLiveSourceHistory, eagerLiveSourceProperty], model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], grammar: [historyArb], diff --git a/packages/preact-db/tests/bound-options-oracle.test.ts b/packages/preact-db/tests/bound-options-oracle.test.ts index 5e9023ab8b..340bde1f3d 100644 --- a/packages/preact-db/tests/bound-options-oracle.test.ts +++ b/packages/preact-db/tests/bound-options-oracle.test.ts @@ -20,6 +20,7 @@ import { checkBoundOptions, } from '../../db/tests/live-query-bound-options-oracle' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { renderHook, settle } from './render-probe' import type { InitialQueryBuilder } from '@tanstack/db' @@ -43,10 +44,15 @@ afterAll(() => { Object.fromEntries(reach.counts()), ) }) -declareOracle({ check: [checkBoundOptions], grammar: [boundOptionHistories] }) +declareOracle({ + owns: [`src/useLiveQuery.ts#useLiveQuery`], + check: [checkBoundOptions], + grammar: [boundOptionHistories], +}) test.each(boundOptionHistories)( `Preact retains bound options %j`, async ({ option, identity }) => { + oracleRoute(`case`) reach.beginHistory() const source = createCollection( mockSyncCollectionOptions({ @@ -146,6 +152,7 @@ test.each(boundOptionHistories)( test.each(boundOptionHistories)( `bound-option checker rejects in-place replacement %j`, ({ option }) => { + oracleRoute(`calibration`) const collection = { config: { getKey: () => `initial`, schema: {} } } const initial = captureBoundOptions(collection) if (option !== `schema`) collection.config.getKey = () => `replacement` diff --git a/packages/preact-db/tests/bound-schema-options-oracle.test.ts b/packages/preact-db/tests/bound-schema-options-oracle.test.ts index 19e8e435e2..e2c1201799 100644 --- a/packages/preact-db/tests/bound-schema-options-oracle.test.ts +++ b/packages/preact-db/tests/bound-schema-options-oracle.test.ts @@ -8,6 +8,7 @@ import { act } from 'preact/test-utils' import { afterAll, test } from 'vitest' import { useLiveQuery } from '../src/useLiveQuery' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { boundSchemaHistories, @@ -32,6 +33,7 @@ afterAll(() => { test.each(boundSchemaHistories)( `Preact binds mutation schemas %j`, async (history) => { + oracleRoute(`case`) ran = true const host: BoundSchemaNativeHost = { mount: (input) => { @@ -57,6 +59,7 @@ test.each(boundSchemaHistories)( }, ) declareOracle({ + owns: [`src/useLiveQuery.ts#useLiveQuery`], grammar: [boundSchemaHistories], check: [checkBoundSchemaMutation], }) diff --git a/packages/preact-db/tests/conformance.test.ts b/packages/preact-db/tests/conformance.test.ts index 4b876f74af..2834662120 100644 --- a/packages/preact-db/tests/conformance.test.ts +++ b/packages/preact-db/tests/conformance.test.ts @@ -278,7 +278,7 @@ const preactDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/preact-db/tests/infinite-query-conformance.test.ts b/packages/preact-db/tests/infinite-query-conformance.test.ts index c5d7f5b517..e972e3ad9a 100644 --- a/packages/preact-db/tests/infinite-query-conformance.test.ts +++ b/packages/preact-db/tests/infinite-query-conformance.test.ts @@ -273,7 +273,7 @@ const preactInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(preactInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/packages/preact-db/tests/window-render-oracle.test.tsx b/packages/preact-db/tests/window-render-oracle.test.tsx index 51f954c8c6..91b3a41329 100644 --- a/packages/preact-db/tests/window-render-oracle.test.tsx +++ b/packages/preact-db/tests/window-render-oracle.test.tsx @@ -32,8 +32,11 @@ import { } from '../../db/tests/oracle-failure-fidelity' import { declareOracle, + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { settle } from './render-probe' @@ -254,19 +257,18 @@ describe(`window changes at Preact commit`, () => { }, ) try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2291, { + numRuns: oracleRuns(6) + examples.length, + examples: [...examples].map(([value]) => [value]), + }) + : oraclePropertyOptions(6, property, { + examples: [...examples].map(([value]) => [value]), + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => - fc.assert( - propertyUnderTest, - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + examples.length, - seed: 2291, - examples: [...examples].map(([value]) => [value]), - } - : oraclePropertyOptions(6, property, { - examples: [...examples].map(([value]) => [value]), - }), - ), + fc.assert(propertyUnderTest, parameters), ) } finally { console.info( @@ -280,6 +282,7 @@ describe(`window changes at Preact commit`, () => { }) declareOracle({ + owns: [`src/useLiveQuery.ts#useLiveQuery`], grammar: [renderHistoryArbitrary], check: [checkRenderHistory], }) 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 dab72779e3..06fb3e862a 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 @@ -3,10 +3,14 @@ import { createCollection } from '@tanstack/db' import { describe, expect, it, vi } from 'vitest' import fc from 'fast-check' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../../db/tests/oracle-config.js' +import { declareOracle } from '../../db/tests/oracle-declaration.js' import { createLiveQueryWindowController } from '../../db/src/live-query-window-controller.js' import { createCursorPager, queryCollectionOptions } from '../src/index.js' import { @@ -58,6 +62,17 @@ const manualWriteCampaigns = ? ([`replay`] as const) : ([] as const) +// What this oracle owns: the opaque pager through a real QueryCollection and +// live-query window (createCursorPager, queryCollectionOptions). Its model is +// the full-relation window in cursor-pagination/model-oracle.ts. +declareOracle({ + owns: [ + `src/cursor-pagination.ts#createCursorPager`, + `src/query.ts#queryCollectionOptions`, + ], + model: [expectedWindow], +}) + describe(`cursor adapter through production pagination`, () => { // A handler-free direct write surgically changes an eager Query Collection's // rows and compatible row-cache arrays. It must preserve an envelope's other @@ -76,7 +91,7 @@ describe(`cursor adapter through production pagination`, () => { // cursor pager. Immediately after each direct write it compares public rows // and row-cache data with the fold, then checks that the page Query retains // its prior state and the pager retains its original rows. Fixed seed - // 20261012 and seedless campaigns use the same property and 50-run budget. + // 20261012 and random campaigns use the same property and 50-run budget. // Replay one shrink directly with TANSTACK_DB_ORACLE_SEED, _PATH, and // _PROPERTY=cursor-pagination.manual-write. it.each( @@ -93,6 +108,11 @@ describe(`cursor adapter through production pagination`, () => { const cleanupFailures: Array = [] let lawFailure: unknown try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(20261012, { numRuns: oracleRuns(50) }) + : oraclePropertyOptions(50, manualWriteProperty) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.array( @@ -210,9 +230,7 @@ describe(`cursor adapter through production pagination`, () => { } }, ), - campaign === `fixed` - ? { seed: 20261012, numRuns: oracleRuns(50) } - : oraclePropertyOptions(50, manualWriteProperty), + parameters, ) } catch (error) { lawFailure = error @@ -242,6 +260,7 @@ describe(`cursor adapter through production pagination`, () => { )( `refreshes changed backend rows before growing the window $descending/$backendSize`, async ({ descending, backendSize }) => { + oracleRoute(`case`) const rows = Array.from({ length: 3 }, (_, id) => ({ id, rank: 0, @@ -303,6 +322,7 @@ describe(`cursor adapter through production pagination`, () => { it.each(matrix)( `publishes exact pages and exhaustion $count/$backendSize/$pageSize/$descending`, async ({ count, backendSize, pageSize, descending }) => { + oracleRoute(`case`) const rows = Array.from({ length: count }, (_, id) => ({ id, rank: Math.floor(id / 6), diff --git a/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts b/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts index 1a961076a0..fe233b39a0 100644 --- a/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts @@ -22,6 +22,8 @@ import { import { describe, expect, it } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { createLiveQueryWindowController } from '../../db/src/live-query-window-controller.js' +import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config.js' import { expectedWindow } from './cursor-pagination/model-oracle.js' import { createNoPeekDemandModel } from './cursor-pagination/no-peek.js' import { createFactTransport } from './cursor-pagination/no-peek-transport.js' @@ -30,6 +32,12 @@ import type { PrefixPublication } from './cursor-pagination/no-peek.js' const scope = { group: undefined, descending: false } +// What this oracle owns: nothing in src/. The candidate under test is the +// test-only experiment in cursor-pagination/no-peek.ts, and the live-query window +// controller it drives belongs to the db package. The file declares its +// model only; its tests are enumerated fixed cases. +declareOracle({ model: [expectedWindow] }) + /** Narrow bridge for one immutable source ordered by unique id. Facts are kept * outside production and admitted only after the real setWindow/preload gate. * This intentionally does not claim generic compiler eligibility or automatic @@ -142,6 +150,7 @@ describe(`no-peek bridge at real graph publication`, () => { it.each([0, 3, 4, 9])( `matches peek-ahead output with %s remote rows`, async (count) => { + oracleRoute(`case`) const candidate = createGraphFixture(count, 3) const baseline = createGraphFixture(count, 3) const model = createNoPeekDemandModel(candidate.acquire) @@ -174,6 +183,7 @@ describe(`no-peek bridge at real graph publication`, () => { ) it(`holds continuation behind actual source and graph publication`, async () => { + oracleRoute(`case`) const fixture = createGraphFixture(9, 3, true) const model = createNoPeekDemandModel(fixture.acquire) model.retainDemand(`a`, 3) @@ -202,6 +212,7 @@ describe(`no-peek bridge at real graph publication`, () => { }) it(`falls back for a local filter even though the order is unchanged`, async () => { + oracleRoute(`case`) const fixture = createGraphFixture(9, 3, false, true) const model = createNoPeekDemandModel(fixture.acquire, false) model.retainDemand(`a`, 3) diff --git a/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts index e050390895..7c718f7686 100644 --- a/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts @@ -5,8 +5,11 @@ import { describe, expect, it, vi } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { oraclePropertyOptions, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../../db/tests/oracle-config.js' +import { declareOracle } from '../../db/tests/oracle-declaration.js' import { createCursorPager, queryCollectionOptions } from '../src/index.js' import { makeClient, @@ -43,8 +46,10 @@ async function assertBoundaryProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(30, name) - if (replay.replaySeed === undefined) + if (replay.campaignSeed === undefined) { await fc.assert(property, { ...options, seed: fixedSeed }) + } + oracleRoute(oracleRouteOf(options)) await fc.assert(property, options) } @@ -70,6 +75,16 @@ async function finishBoundaryCleanup( ) } +// What this oracle owns: the cursor pager's reader and nested-query boundaries +// (createCursorPager) and the QueryCollection preload that reads through it +// (queryCollectionOptions). Its generated properties carry their own checks. +declareOracle({ + owns: [ + `src/cursor-pagination.ts#createCursorPager`, + `src/query.ts#queryCollectionOptions`, + ], +}) + describe(`cursor Query fetch boundaries`, () => { it.each([false, true])( `nested row query settles when pages cancel; replacement=%s`, diff --git a/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts index b8d8a30665..e6777b678f 100644 --- a/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts @@ -4,8 +4,11 @@ import { describe, expect, it, vi } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { oraclePropertyOptions, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../../db/tests/oracle-config.js' +import { declareOracle } from '../../db/tests/oracle-declaration.js' import { createCursorPager } from '../src/index.js' import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model-oracle.js' @@ -44,8 +47,10 @@ async function assertCacheProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(runs, name) - if (replay.replaySeed === undefined) + if (replay.campaignSeed === undefined) { await fc.assert(property, { ...options, seed: fixedSeed }) + } + oracleRoute(oracleRouteOf(options)) await fc.assert(property, options) } @@ -67,6 +72,15 @@ async function checkWindow( ).toEqual(expectedRows(source, scope, { offset: 0, limit: width })) } +// What this oracle owns: cache reuse, expiry, invalidation and cancellation of +// the pager's Query pages (createCursorPager). Window values are judged by +// checkWindow against the full-relation model. +declareOracle({ + owns: [`src/cursor-pagination.ts#createCursorPager`], + check: [checkWindow], + model: [expectedRows], +}) + describe(`cursor cache lifecycle`, () => { it.each([ [`global`, `maxPages`], @@ -261,6 +275,7 @@ describe(`cursor cache lifecycle`, () => { ) it(`the cache oracle rejects reuse after invalidation and accepts a real refresh`, async () => { + oracleRoute(`calibration`) for (const ignoreInvalidation of [false, true]) { const client = new QueryClient({ defaultOptions: { @@ -297,6 +312,7 @@ describe(`cursor cache lifecycle`, () => { }) it(`reuses a cache one millisecond before expiry and refreshes at expiry`, async () => { + oracleRoute(`case`) // A 99 ms cutoff refetches too soon; a 101 ms cutoff reuses stale rows. vi.useFakeTimers({ toFake: [`Date`] }) vi.setSystemTime(1000) @@ -332,6 +348,7 @@ describe(`cursor cache lifecycle`, () => { }) it(`QueryClient cancellation rejects held work without installing its late page`, async () => { + oracleRoute(`case`) const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false }, @@ -486,6 +503,7 @@ describe(`cursor cache lifecycle`, () => { it.each([0, 25])( `collects inactive pages independently of freshness with gcTime %i`, async (gcTime) => { + oracleRoute(`case`) vi.useFakeTimers() const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -524,6 +542,7 @@ describe(`cursor cache lifecycle`, () => { it.each([`expiry`, `invalidate`] as const)( `keeps cached data while a %s refresh is held or fails`, async (cause) => { + oracleRoute(`case`) vi.useFakeTimers({ toFake: [`Date`] }) vi.setSystemTime(1000) const client = new QueryClient({ @@ -573,6 +592,7 @@ describe(`cursor cache lifecycle`, () => { ) it(`shares Query fetches across readers without letting one abort cancel its peer`, async () => { + oracleRoute(`case`) const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: Infinity }, diff --git a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts index 402d2c5bca..d9ee8ca105 100644 --- a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts @@ -1,13 +1,22 @@ import fc from 'fast-check' import { describe, expect, it, vi } from 'vitest' +import { + differs, + oracleProperty, + oracleRouteOf, + pass, +} from '../../db/tests/oracle-property.js' import { createDeferred } from '../../db/src/deferred.js' import { oraclePropertyOptions, + oracleRoute, readOracleRunConfig, } from '../../db/tests/oracle-config.js' +import { declareOracle } from '../../db/tests/oracle-declaration.js' import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model-oracle.js' import { createCursorPager } from './cursor-pagination/pager.js' +import type { Observation } from '../../db/tests/oracle-property.js' import type { Row, Scope, Window } from './cursor-pagination/model-oracle.js' /** @@ -68,9 +77,10 @@ async function assertCursorProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(runs, name) - if (replay.replaySeed === undefined) { + if (replay.campaignSeed === undefined) { await fc.assert(property, { ...options, seed: fixedSeed }) } + oracleRoute(oracleRouteOf(options)) await fc.assert(property, options) } @@ -119,6 +129,7 @@ async function checkRead( describe(`cursor pagination reference`, () => { it(`preserves ties, filtering and reverse total order`, () => { + oracleRoute(`case`) expect( expectedRows( fixtureRows, @@ -132,6 +143,7 @@ describe(`cursor pagination reference`, () => { }) it(`rejects capped-page, wrong-order and duplicate-row answers`, async () => { + oracleRoute(`calibration`) const window = { offset: 0, limit: 8 } const expected = expectedRows(fixtureRows, allAscending, window) for (const wrong of [ @@ -152,99 +164,6 @@ describe(`cursor pagination reference`, () => { }) describe(`opaque cursor adapter`, () => { - it.each([ - { offset: -1, limit: 1 }, - { offset: 0.5, limit: 1 }, - { offset: Infinity, limit: 1 }, - { offset: NaN, limit: 1 }, - { offset: 0, limit: -1 }, - { offset: 0, limit: 0.5 }, - { offset: 0, limit: Infinity }, - { offset: 0, limit: NaN }, - { offset: Number.MAX_SAFE_INTEGER, limit: 1 }, - ])( - `rejects invalid windows before transport: $offset/$limit`, - async (window) => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - await expect(pager.read(window)).rejects.toThrow(RangeError) - expect(backend.calls).toEqual([]) - await checkRead( - fixtureRows, - allAscending, - { offset: 0, limit: 2 }, - pager.read, - ) - }, - ) - - it(`accepts the largest safe window end`, async () => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - // Rejecting at MAX_SAFE_INTEGER would pass the invalid-window cases above. - await checkRead( - fixtureRows, - allAscending, - { offset: Number.MAX_SAFE_INTEGER - 1, limit: 1 }, - pager.read, - ) - expect(backend.calls).toHaveLength(Math.ceil(fixtureRows.length / 2)) - }) - - it(`captures each requested window before queued work starts`, async () => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - const window = { offset: 1, limit: 3 } - const expected = expectedRows(fixtureRows, allAscending, window) - const pending = pager.read(window) - window.offset = 7 - window.limit = 1 - expect(await pending).toEqual(expected) - }) - - it.each([1, 2, 5, 50])( - `fulfills windows across backend pages of %i`, - async (pageSize) => { - const backend = createBackend(fixtureRows, allAscending, pageSize) - const pager = createCursorPager(backend.fetchPage) - for (const window of [ - { offset: 0, limit: 0 }, - { offset: 0, limit: 3 }, - { offset: 2, limit: 6 }, - { offset: 0, limit: undefined }, - { offset: 30, limit: 2 }, - ]) { - await checkRead(fixtureRows, allAscending, window, pager.read) - } - expect(backend.calls).toHaveLength( - Math.ceil(fixtureRows.length / pageSize), - ) - }, - ) - - it(`matches whole-relation slices across random window histories`, async () => { - await assertCursorProperty( - fc.asyncProperty( - rowsArbitrary, - scopeArbitrary, - fc.integer({ min: 1, max: 8 }), - fc.array(windowArbitrary, { minLength: 1, maxLength: 20 }), - async (rows, scope, pageSize, windows) => { - const backend = createBackend(rows, scope, pageSize) - const pager = createCursorPager(backend.fetchPage) - for (const window of windows) - await checkRead(rows, scope, window, pager.read) - const calls = backend.calls.length - for (const window of [...windows].reverse()) - await checkRead(rows, scope, window, pager.read) - expect(backend.calls).toHaveLength(calls) - }, - ), - 100, - `cursor-pagination.history`, - ) - }) - it(`keeps answers invariant under backend page repartition`, async () => { await assertCursorProperty( fc.asyncProperty( @@ -271,6 +190,7 @@ describe(`opaque cursor adapter`, () => { it.each([`abort`, `reject`, `reset`] as const)( `keeps a healthy peer usable after held response %s`, async (action) => { + oracleRoute(`case`) const backend = createBackend(fixtureRows, allAscending, 2) const gate = createDeferred() const abort = new AbortController() @@ -321,25 +241,6 @@ describe(`opaque cursor adapter`, () => { }, ) - it(`serializes unequal peer windows without refetching cached pages`, async () => { - const backend = createBackend(fixtureRows, allAscending, 2) - const pager = createCursorPager(backend.fetchPage) - const windows = [ - { offset: 0, limit: 3 }, - { offset: 2, limit: 6 }, - { offset: 0, limit: 2 }, - ] - const results = await Promise.all( - windows.map((window) => pager.read(window)), - ) - expect(results).toEqual( - windows.map((window) => expectedRows(fixtureRows, allAscending, window)), - ) - expect(backend.calls).toHaveLength(4) - await checkRead(fixtureRows, allAscending, windows[0]!, pager.read) - expect(backend.calls).toHaveLength(4) - }) - it(`resets source generations and isolates filter/order scopes`, async () => { await assertCursorProperty( fc.asyncProperty( @@ -424,3 +325,418 @@ describe(`opaque cursor adapter`, () => { ) }) }) + +// What this oracle owns: the opaque cursor pager that createCursorPager builds +// over a backend. The pager's reads must equal the full-relation model (see +// the declaration below), and its fetched-page counts must follow the +// transport rules. +declareOracle({ + owns: [`src/cursor-pagination.ts#createCursorPager`], + check: [checkRead], + model: [expectedRows], +}) + +// --------------------------------------------------------------------------- +// Read histories over one pager, on oracleProperty +// --------------------------------------------------------------------------- +// +// Law (cursor-pagination/README.md, "Contract before implementation"): a +// successful read returns exactly the requested slice of the whole filtered, +// ordered relation, or fewer rows only at exhaustion; an invalid window +// rejects with RangeError before transport; reads of one pager serialize and +// reuse fetched pages; zero limit performs no transport work; short and empty +// nonterminal pages do not establish exhaustion. So the pages fetched after +// any step are the shortest prefix of the backend's pages that covers every +// window read so far, or every page when a window drains the source. +// +// History: rows, a scope, a backend page size (1 to 8, or 50, larger than +// any relation here), and whether the backend's +// first page is empty and nonterminal; then one to six steps, each one to +// three windows read at once, valid or invalid, with the caller's window +// objects optionally changed right after each call; then every valid window +// read again in reverse order, which must fetch nothing. +// +// Model: `expectedRows` for each valid window, `RangeError` for each invalid +// one, and the page count from the backend's page sizes alone (the model +// never sees tokens). That the count is the shortest covering prefix is not +// one README sentence: it follows from its transport rules with the exact +// counts the previous fixed tests asserted (three peer windows on pages of two +// fetch 4 pages; a drained source fetches every page). Production driver: the production pager over the opaque backend; +// it records each read's rows or error name and the backend's call count +// after each step. Limits: one scope per pager, no reset, faults, or +// cancellation (the reset and failure properties below own those). + +const invalidWindows: ReadonlyArray = [ + { offset: -1, limit: 1 }, + { offset: 0.5, limit: 1 }, + { offset: Infinity, limit: 1 }, + { offset: NaN, limit: 1 }, + { offset: 0, limit: -1 }, + { offset: 0, limit: 0.5 }, + { offset: 0, limit: Infinity }, + { offset: 0, limit: NaN }, + { offset: Number.MAX_SAFE_INTEGER, limit: 1 }, +] +type Read = { window: Window } | { invalid: number } +type ReadHistory = { + rows: Array + scope: Scope + pageSize: number + emptyFirst: boolean + steps: Array<{ reads: Array; changeAfterCall: boolean }> +} +const readArbitrary: fc.Arbitrary = fc.oneof( + { weight: 6, arbitrary: fc.record({ window: windowArbitrary }) }, + // The window whose end is the largest safe integer is valid. A pager that + // rejected at MAX_SAFE_INTEGER would still pass every invalid window. + { + weight: 1, + arbitrary: fc.record({ + window: fc.record({ + offset: fc.constant(Number.MAX_SAFE_INTEGER - 1), + limit: fc.constant(1), + }), + }), + }, + { + weight: 1, + arbitrary: fc.record({ + invalid: fc.integer({ min: 0, max: invalidWindows.length - 1 }), + }), + }, +) +const readHistory: fc.Arbitrary = fc.record({ + rows: rowsArbitrary, + scope: scopeArbitrary, + // A page of 50 is larger than any relation here: one terminal page. + pageSize: fc.oneof( + { weight: 4, arbitrary: fc.integer({ min: 1, max: 8 }) }, + { weight: 1, arbitrary: fc.constant(50) }, + ), + emptyFirst: fc.boolean(), + steps: fc.array( + fc.record({ + reads: fc.array(readArbitrary, { minLength: 1, maxLength: 3 }), + changeAfterCall: fc.boolean(), + }), + // Up to 30 reads, past the 20 sequential windows the previous history + // property drew. + { minLength: 1, maxLength: 10, size: `medium` }, + ), +}) + +const windowOf = (read: Read): Window => + `window` in read ? read.window : invalidWindows[read.invalid]! +const validWindows = ({ steps }: ReadHistory) => + steps.flatMap(({ reads }) => + reads.flatMap((read) => (`window` in read ? [read.window] : [])), + ) + +type ReadResult = { rows: Array } | { error: string } +type StepRecord = { results: Array; calls: number } + +/** The backend's page sizes, from the filtered row count alone. */ +function pageSizes({ rows, scope, pageSize, emptyFirst }: ReadHistory) { + const count = expectedRows(rows, scope, { + offset: 0, + limit: undefined, + }).length + const pages = Array.from( + { length: Math.max(1, Math.ceil(count / pageSize)) }, + (_, index) => Math.min(pageSize, count - index * pageSize), + ) + return { count, pages: emptyFirst ? [0, ...pages] : pages } +} + +/** Pages a window needs: the shortest covering prefix, or every page. */ +function pagesFor(history: ReadHistory, { offset, limit }: Window): number { + if (limit === 0) return 0 + const { pages } = pageSizes(history) + if (limit === undefined) return pages.length + let rows = 0 + for (const [index, size] of pages.entries()) { + rows += size + if (rows >= offset + limit) return index + 1 + } + return pages.length +} + +function readModel(history: ReadHistory): Map { + const owed = new Map() + let calls = 0 + const resultOf = (read: Read): ReadResult => + `window` in read + ? { rows: expectedRows(history.rows, history.scope, read.window) } + : { error: `RangeError` } + for (const [index, { reads }] of history.steps.entries()) { + for (const read of reads) + if (`window` in read) + calls = Math.max(calls, pagesFor(history, read.window)) + owed.set(`step ${index}`, { results: reads.map(resultOf), calls }) + } + owed.set(`reread`, { + results: [...validWindows(history)].reverse().map((window) => ({ + rows: expectedRows(history.rows, history.scope, window), + })), + calls, + }) + return owed +} + +async function driveReads( + history: ReadHistory, + observe: (checkpoint: string, value: unknown) => void, +) { + const backend = createBackend( + history.rows, + history.scope, + history.pageSize, + history.emptyFirst, + ) + const pager = createCursorPager(backend.fetchPage) + const settle = (promise: Promise>) => + promise.then( + (rows): ReadResult => ({ rows }), + // An invalid window must reject with a RangeError instance; any other + // rejection keeps its name and message for the failure. + (error: unknown): ReadResult => ({ + error: + error instanceof RangeError + ? `RangeError` + : error instanceof Error + ? `${error.name}: ${error.message}` + : String(error), + }), + ) + for (const [index, { reads, changeAfterCall }] of history.steps.entries()) { + const windows = reads.map((read) => ({ ...windowOf(read) })) + const pending = windows.map((window) => settle(pager.read(window))) + // Reads of one pager queue behind each other, so a window must be + // captured when it is called, not when its queued work starts. + if (changeAfterCall) + for (const window of windows) { + window.offset = 7 + window.limit = 1 + } + observe(`step ${index}`, { + results: await Promise.all(pending), + calls: backend.calls.length, + } satisfies StepRecord) + } + const results: Array = [] + for (const window of [...validWindows(history)].reverse()) + results.push(await settle(pager.read({ ...window }))) + observe(`reread`, { + results, + calls: backend.calls.length, + } satisfies StepRecord) +} + +function checkReads( + _history: ReadHistory, + trace: ReadonlyArray, + owed: ReturnType, +) { + for (const [checkpoint, expected] of owed) { + const seen = trace.find((entry) => entry.checkpoint === checkpoint) + ?.value as StepRecord | undefined + const wrong = + differs( + checkpoint, + `invalid`, + seen?.results.map((result) => `error` in result), + expected.results.map((result) => `error` in result), + `a read rejected or fulfilled against its window's validity`, + ) ?? + differs( + checkpoint, + `slice`, + seen?.results, + expected.results, + `a read returned another slice`, + ) ?? + differs( + checkpoint, + `transport`, + seen?.calls, + expected.calls, + `the pager fetched another number of pages`, + ) + if (wrong) return wrong + } + return pass +} + +const fixedHistory = ( + name: string, + pageSize: number, + steps: ReadHistory[`steps`], +): { name: string; history: ReadHistory } => ({ + name, + history: { + rows: fixtureRows, + scope: allAscending, + pageSize, + emptyFirst: false, + steps, + }, +}) +const readsOf = (...windows: Array) => ({ + reads: windows.map((window) => ({ window })), + changeAfterCall: false, +}) + +oracleProperty({ + id: `cursor-pagination.history`, + law: { + statement: `A cursor pager returns exactly the requested slice of the whole filtered ordered relation, rejects an invalid window with RangeError before transport, captures each window when called, and fetches only the shortest prefix of backend pages that covers what was read, reusing fetched pages.`, + authority: `packages/query-db-collection/tests/cursor-pagination/README.md, "Contract before implementation" (transport rules) and "Chosen implementation" (loading more fetches the missing suffix); the exact page counts the previous fixed tests pinned; the exported createCursorPager API`, + limits: `One scope per pager; no reset, faults, or cancellation in this property; a stable backend relation, not snapshot consistency for changing endpoints.`, + owner: `Opaque backend pagination`, + }, + histories: { + arbitrary: readHistory, + states: [], + measured: [ + `invalid window`, + `peer reads`, + `empty first page`, + `zero limit`, + `window past the end`, + `window changed after call`, + ], + runs: 100, + fixedSeeds: [fixedSeed], + fixed: [ + ...[1, 2, 5, 50].map((pageSize) => + fixedHistory(`windows across backend pages of ${pageSize}`, pageSize, [ + readsOf( + { offset: 0, limit: 0 }, + { offset: 0, limit: 3 }, + { offset: 2, limit: 6 }, + ), + readsOf({ offset: 0, limit: undefined }, { offset: 30, limit: 2 }), + ]), + ), + fixedHistory(`unequal peer windows serialize without refetching`, 2, [ + readsOf( + { offset: 0, limit: 3 }, + { offset: 2, limit: 6 }, + { offset: 0, limit: 2 }, + ), + ]), + // Alone and first, so no peer's fetch hides a zero-limit fetch. + fixedHistory(`a zero limit performs no transport work`, 2, [ + readsOf({ offset: 3, limit: 0 }), + readsOf({ offset: 0, limit: 0 }), + ]), + fixedHistory(`the largest safe window end`, 2, [ + readsOf({ offset: Number.MAX_SAFE_INTEGER - 1, limit: 1 }), + ]), + fixedHistory(`a window changed after its call`, 2, [ + { reads: [{ window: { offset: 1, limit: 3 } }], changeAfterCall: true }, + ]), + ...invalidWindows.map(({ offset, limit }, invalid) => + fixedHistory( + `invalid window ${offset}/${limit} rejects before transport`, + 2, + [ + { reads: [{ invalid }], changeAfterCall: false }, + readsOf({ offset: 0, limit: 2 }), + ], + ), + ), + ], + }, + model: readModel, + reached: (history) => { + const all = history.steps.flatMap(({ reads }) => reads) + const { count } = pageSizes(history) + return [ + ...(all.some((read) => `invalid` in read) ? [`invalid window`] : []), + ...(history.steps.some(({ reads }) => reads.length > 1) + ? [`peer reads`] + : []), + ...(history.emptyFirst ? [`empty first page`] : []), + ...(validWindows(history).some((window) => window.limit === 0) + ? [`zero limit`] + : []), + ...(validWindows(history).some((window) => window.offset > count) + ? [`window past the end`] + : []), + ...(history.steps.some(({ changeAfterCall }) => changeAfterCall) + ? [`window changed after call`] + : []), + ] + }, + driver: driveReads, + check: checkReads, + calibrations: [ + { + name: `a read returns one page only`, + law: `slice`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => + (value as StepRecord).results.some( + (result) => `rows` in result && result.rows.length > 2, + ), + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { + ...entry, + value: { + ...(entry.value as StepRecord), + results: (entry.value as StepRecord).results.map((result) => + `rows` in result + ? { rows: result.rows.slice(0, 2) } + : result, + ), + }, + } + : entry, + ) + }, + }, + { + name: `a reread fetches again`, + law: `transport`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `reread` + ? { + ...entry, + value: { + ...(entry.value as StepRecord), + calls: (entry.value as StepRecord).calls + 1, + }, + } + : entry, + ), + }, + { + name: `an invalid window reaches transport and fulfills`, + law: `invalid`, + corrupt: (trace) => { + const at = trace.findIndex(({ value }) => + (value as StepRecord).results.some((result) => `error` in result), + ) + if (at < 0) return undefined + return trace.map((entry, index) => + index === at + ? { + ...entry, + value: { + ...(entry.value as StepRecord), + results: (entry.value as StepRecord).results.map((result) => + `error` in result ? { rows: [] } : result, + ), + }, + } + : entry, + ) + }, + }, + ], +}) diff --git a/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts index f5ca35b1ef..8e723e2abd 100644 --- a/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts @@ -4,8 +4,11 @@ import { describe, expect, it, vi } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { oraclePropertyOptions, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../../db/tests/oracle-config.js' +import { declareOracle } from '../../db/tests/oracle-declaration.js' import { createCursorPager } from '../src/index.js' import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model-oracle.js' @@ -61,8 +64,10 @@ async function assertPublicationProperty( ): Promise { if (replay.replayPath !== undefined && replay.replayProperty !== name) return const options = oraclePropertyOptions(runs, name) - if (replay.replaySeed === undefined) + if (replay.campaignSeed === undefined) { await fc.assert(property, { ...options, seed: fixedSeed }) + } + oracleRoute(oracleRouteOf(options)) await fc.assert(property, options) } @@ -109,6 +114,15 @@ async function checkCachedSlice( } } +// What this oracle owns: which cursor sequence publishes the next Query result +// and how a cached slice reads its rows (createCursorPager). Slice values are +// judged by checkCachedSlice against the full-relation model. +declareOracle({ + owns: [`src/cursor-pagination.ts#createCursorPager`], + check: [checkCachedSlice], + model: [expectedRows], +}) + describe(`cursor cache publication`, () => { it.each( [true, false].flatMap((cancel) => @@ -385,6 +399,7 @@ describe(`cursor cache publication`, () => { }) it(`a one-row cached slice does not visit its preceding row`, async () => { + oracleRoute(`case`) // A full-prefix traversal visits two rows; the requested slice visits one. await checkCachedSlice(2, 1, 1) }) diff --git a/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md b/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md index 12400618b1..8198d182d5 100644 --- a/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md +++ b/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md @@ -39,9 +39,9 @@ metadata withdrawal, acquisition/fallback failure and healthy peer recovery. ## What the failures taught us The first candidate used the latest response's `hasMore` for all consumers and -treated missing metadata as false. It failed seven of nine tests. Seed `863` -shrunk one failure to two source rows, backend page size one, a one-row window, -and missing metadata. It incorrectly announced the end. +treated missing metadata as false. It failed seven of nine tests. Campaign seed +`863` shrunk one failure to two source rows, backend page size one, a one-row +window, and missing metadata. It incorrectly announced the end. The tested repair has four rules: @@ -103,10 +103,10 @@ replacement for the production controller's lifecycle and error handling. ## Verification - First red: seven assertion failures, two passes; no setup failure or timeout. -- Final fixed-seed campaign: all 66 tests across both experiments passed. - The new no-peek property ran 15,000 histories at multiplier 100, seed `863`; - the preceding cursor experiment ran another 35,000 generated cases. -- A separate default random-seed run covers the no-peek files. +- Final fixed-seed campaign: all 66 tests across both experiments passed. The + new no-peek property ran 15,000 histories at multiplier 100, campaign seed + `863`; the preceding cursor experiment ran another 35,000 generated cases. +- A separate default random campaign covers the no-peek files. - Package TypeScript and lint on all experiment TypeScript passed. From `packages/query-db-collection`: diff --git a/packages/query-db-collection/tests/cursor-pagination/README.md b/packages/query-db-collection/tests/cursor-pagination/README.md index 25040a9209..d7934dede5 100644 --- a/packages/query-db-collection/tests/cursor-pagination/README.md +++ b/packages/query-db-collection/tests/cursor-pagination/README.md @@ -101,8 +101,8 @@ observe production publications, not manufacture them in the fixture. retaining old rows. The Query-owned cache with shared-prefix invalidation passes backend growth, shrink, empty and regrowth plus later page expansion. - The frozen candidate passed 87 tests across five files with multiplier 100, - seed 863: 35,000 cursor histories, 10,000 cache histories and 15,000 no-peek - experiment histories. The latter are retained experiments, not shipped + campaign seed 863: 35,000 cursor histories, 10,000 cache histories and 15,000 + no-peek experiment histories. The latter are retained experiments, not shipped metadata behavior. A loss audit and final package gates follow. The cache oracle uses a full source snapshot plus a freshness deadline, not a @@ -123,7 +123,7 @@ failed eight cells before production changed (seed 863, minimized size/depth `[1, 1]`): global/key `maxPages`, `select`, their combination, and cancellation with an existing prefix during growth/refresh. Initial cancellation stayed green. -The fixes pin the internal page format and preserve Query fetch rejection even +The fixes check the internal page format and preserve Query fetch rejection even when Query's fetch API returns reverted cache data. Generated cancellation histories hold actual response delivery at varied page depths, observe a real peer fetch join, assert both waiters reject without replacement transport, fence @@ -142,7 +142,7 @@ See LOSS-AUDIT.md for the distinct generator and observation gaps. Verification for the Query fetch boundary review fixes on base head `3f43deeb6`: -- Full Query DB package: **370 tests in 14 files**, default random-seed lane, +- Full Query DB package: **370 tests in 14 files**, default random campaign, exit 0, 7.82 seconds. - Final stress: **116 tests in seven files**, multiplier 100, seed 863, exit 0, 73.48 seconds. 154,000 generated histories: the previous 135,000 plus @@ -212,12 +212,12 @@ safety of removing peek-ahead or adding a public metadata API. unlimited read: the reader returned only the first row. - Negative control: removing the post-response cancellation/generation check failed both held-response abort and reset cases. The rejection control still - passed. Restoring the check restored green; these were assertion failures, - not timeouts. -- Final green: 48 tests, including 32 backend/UI page-size integration cells - and a held-response shared-consumer case. Four generated properties ran - 35,000 cases in total at multiplier 100 with seed `863`. A separate normal - random-seed run also passed. + passed. Restoring the check restored green; these were assertion failures, not + timeouts. +- Final green: 48 tests, including 32 backend/UI page-size integration cells and + a held-response shared-consumer case. Four generated properties ran 35,000 + cases in total at multiplier 100 with campaign seed `863`. A separate normal + random campaign also passed. - Package TypeScript check and lint on all changed TypeScript files passed. From `packages/query-db-collection`, using the installed local binaries: diff --git a/packages/query-db-collection/tests/documented-mutations-oracle.test.ts b/packages/query-db-collection/tests/documented-mutations-oracle.test.ts index 58d0be210b..2040f2e9d9 100644 --- a/packages/query-db-collection/tests/documented-mutations-oracle.test.ts +++ b/packages/query-db-collection/tests/documented-mutations-oracle.test.ts @@ -11,7 +11,7 @@ import { createCollection } from '@tanstack/db' import { queryCollectionOptions } from '../src/query' import { createDeferred } from '../../db/src/deferred' import { declareOracle } from '../../db/tests/oracle-declaration' -import { oracleReplayTest } from '../../db/tests/oracle-config' +import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' import { checkDocumentedMutation, documentedMutationHistories, @@ -33,6 +33,7 @@ afterAll(() => reach.finish()) oracleReplayTest(test).each(histories)( `runs the actual documented handler: $id`, async (history) => { + oracleRoute(`case`) reach.begin(history.id) await runDocumentedMutation( history, @@ -108,7 +109,11 @@ oracleReplayTest(test).each(histories)( }, ) +// What this oracle owns: the documented Query handlers run through +// queryCollectionOptions, whose mutation confirmation and refetch wrappers the +// documentation grammar judges. declareOracle({ + owns: [`src/query.ts#queryCollectionOptions`], check: [checkDocumentedMutation], grammar: [documentedMutationHistories], }) diff --git a/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts index 0055b1b4fa..d51b7bc487 100644 --- a/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts +++ b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts @@ -8,6 +8,8 @@ import { import { QueryClient } from '@tanstack/query-core' import { describe, expect, it } from 'vitest' import { evaluateReferenceExpression } from '../../db/tests/reference-expression-oracle' +import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config.js' import { queryCollectionOptions } from '../src/query' import type { Collection } from '@tanstack/db' @@ -502,12 +504,27 @@ function expectIrrelevantBranchWork( ) } +// What this oracle owns: the source Query collections that queryCollectionOptions +// builds for each level of the nested includes tree. Its model is the +// reachable tree derived from the source foreign keys. +declareOracle({ + owns: [`src/query.ts#queryCollectionOptions`], + model: [expectedTreeEntries, expectedTreeCounts], + check: [ + checkNestedTreeShape, + expectNestedTreeShape, + expectNestedTreeWork, + expectIrrelevantBranchWork, + ], +}) + describe(`nested includes reachable-shape oracle`, () => { // Keep every value from the former fixed-seed sample (2, 3, 4, 13, 17), // plus nearby sizes. The empty and reported-size endpoints stay explicit. it.each([1, 2, 3, 4, 5, 10, 13, 17])( `preserves the complete reachable nested tree shape for %i roots`, async (rootCount) => { + oracleRoute(`case`) await checkNestedTreeShape(rootCount, (observation) => expectNestedTreeShape(observation, rootCount), ) @@ -515,20 +532,23 @@ describe(`nested includes reachable-shape oracle`, () => { ) it(`exposes no nested collections for an empty root query`, async () => { + oracleRoute(`case`) await checkNestedTreeShape(0, (observation) => expectNestedTreeShape(observation, 0), ) }) - it(`pins the reported 20-by-2-by-5-by-10 tree`, async () => { + it(`checks the reported 20-by-2-by-5-by-10 tree`, async () => { + oracleRoute(`case`) // These semantic counters do not claim to measure elapsed time or internal - // allocations. They pin source delivery at preload and reachable shape. + // allocations. They fix source delivery at preload and reachable shape. await checkNestedTreeShape(20, (observation) => expectNestedTreeShape(observation, 20), ) }) it(`does not deliver more source rows while traversing the result`, async () => { + oracleRoute(`case`) await checkNestedTreeShape(20, (observation) => { expect(observation.sourceRowsDeliveredAfterTraversal).toEqual( observation.sourceRowsDeliveredAtPreload, @@ -539,6 +559,7 @@ describe(`nested includes reachable-shape oracle`, () => { it.each([1, 3])( `does not acquire or deliver %i disconnected branches at preload`, async (irrelevantBranchCount) => { + oracleRoute(`case`) const observations: Array = [] await checkNestedTreeShape( 2, @@ -580,6 +601,7 @@ describe(`nested includes reachable-shape oracle`, () => { ) it(`rejects a repeated source row delivery after traversal`, async () => { + oracleRoute(`calibration`) await checkNestedTreeShape(2, (observation) => { expectNestedTreeShape(observation, 2) const repeatedDelivery: NestedTreeShape = { @@ -596,6 +618,7 @@ describe(`nested includes reachable-shape oracle`, () => { it.each([`subtree swap`, `value corruption`] as const)( `rejects %s even when reachable cardinality and source work agree`, async (fault) => { + oracleRoute(`calibration`) await checkNestedTreeShape(2, (observation) => expectNestedTreeShape(observation, 2), ) diff --git a/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts b/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts index 86e6fa68bf..524ad1221d 100644 --- a/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts +++ b/packages/query-db-collection/tests/optimistic-ordered-refetch-oracle.test.ts @@ -10,6 +10,7 @@ import { createDeferred } from '../../db/src/deferred.js' import { queryCollectionOptions } from '../src/query.js' import { applyPredicates } from '../e2e/query-filter.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config.js' import { createOracleReach } from '../../db/tests/oracle-reach' import { withOracleCleanup } from '../../db/tests/utils' import { atMutationCheckpoint } from '../../db/tests/mutation-confirmation-support' @@ -376,6 +377,7 @@ function checkOrderedMutationObservation( } declareOracle({ + owns: [`src/query.ts#queryCollectionOptions`], check: [checkOptimisticOrderedRefetch, checkOrderedMutationObservation], model: [expectedPublicWindow], grammar: [orderedMutationHistories], @@ -393,7 +395,10 @@ describe(`ordered mutation histories`, () => { }) it.each(orderedMutationHistories)( `preserves ordered mutation outcomes: %j`, - checkOptimisticOrderedRefetch, + (history) => { + oracleRoute(`case`) + return checkOptimisticOrderedRefetch(history) + }, ) }) @@ -404,6 +409,7 @@ it.each([ `discarded-value-failure`, `reversed-order`, ] as const)(`rejects ordered wrong observation: %s`, (fault) => { + oracleRoute(`calibration`) const remote: Array = [ { id: 1, folder: `inbox`, at: 1 }, { id: 2, folder: `inbox`, at: 2 }, diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index 5156a7df4d..72084d0978 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -11,6 +11,7 @@ import { } from '@tanstack/db' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config.js' import { createOracleReach } from '../../db/tests/oracle-reach' import { withOracleCleanup } from '../../db/tests/utils' import { atMutationCheckpoint } from '../../db/tests/mutation-confirmation-support' @@ -1646,7 +1647,11 @@ function checkPostHandlerRefetch( } } +// What this oracle owns: the query collection's mutation confirmation, explicit +// refetch application, fetch-record ownership and reacquisition (queryCollectionOptions +// and the manual-sync write path it reaches). declareOracle({ + owns: [`src/query.ts#queryCollectionOptions`], check: [ checkMutationConfirmation, checkPostHandlerRefetch, @@ -1701,6 +1706,7 @@ describe(`query collection ownership lifecycle`, () => { it.each(mutationHistories)( `refreshes only when the handler requests it: %j`, async (history) => { + oracleRoute(`case`) mutationReach.beginHistory() const { operation, confirmation, outcome, returned } = history const localReach = createOracleReach(`query.mutation-cuts`, [ @@ -1968,6 +1974,7 @@ describe(`query collection ownership lifecycle`, () => { it.each([`early-receipt`, `partial-rows`, `stale-waiter`] as const)( `rejects post-handler wrong observation: %s`, (fault) => { + oracleRoute(`calibration`) let model = createRefetchCallSettlementModel([`query`], true) model = reduceRefetchCallSettlement(model, { type: `accept-application`, @@ -2003,6 +2010,7 @@ describe(`query collection ownership lifecycle`, () => { it.each(histories)( `waits for later application after handler $operation rejection:$reject`, async ({ operation, reject }) => { + oracleRoute(`case`) reach.beginHistory() const initial: Array = [ { id: `peer`, category: `peer`, name: `Peer` }, @@ -2174,6 +2182,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`keeps an accepted empty-diff result pending until application`, () => { + oracleRoute(`case`) const initial: ExplicitRefetchApplicationModel = { calls: new Map(), publicValues: new Map([[`query`, `same`]]), @@ -2214,6 +2223,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`retains separate outcomes for a retired caller and its successor`, () => { + oracleRoute(`case`) let model: ExplicitRefetchApplicationModel = { calls: new Map(), publicValues: new Map([[`query`, `initial`]]), @@ -2279,6 +2289,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects a fetch-boundary-only wrong-result control`, () => { + oracleRoute(`calibration`) let model: ExplicitRefetchApplicationModel = { calls: new Map(), publicValues: new Map([[`query`, `Optimistic`]]), @@ -2312,6 +2323,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects illegal explicit-refetch model histories`, () => { + oracleRoute(`calibration`) const started = reduceExplicitRefetchApplication( { calls: new Map(), publicValues: new Map() }, { @@ -2366,6 +2378,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`keeps a suppressed fetch failure pending for its accepted application`, () => { + oracleRoute(`case`) let model = createRefetchCallSettlementModel([`query`], false) model = reduceRefetchCallSettlement(model, { type: `accept-application`, @@ -2393,6 +2406,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`excludes unrelated background applications from a refetch call`, () => { + oracleRoute(`case`) let model = createRefetchCallSettlementModel([`query`], true) model = reduceRefetchCallSettlement(model, { type: `accept-application`, @@ -2428,6 +2442,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`preserves the result slot of a Query removed during refetch startup`, () => { + oracleRoute(`case`) let model = createRefetchCallSettlementModel([`first`, `second`], true) model = reduceRefetchCallSettlement(model, { type: `accept-application`, @@ -2456,6 +2471,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects fail-fast while an independent application remains pending`, () => { + oracleRoute(`case`) let model = createRefetchCallSettlementModel([`detail`, `list`], true) model = reduceRefetchCallSettlement(model, { type: `accept-application`, @@ -2487,6 +2503,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects a handler call that resolves before its Query fetch`, () => { + oracleRoute(`calibration`) const model: HandlerRefetchBoundaryModel = { fetch: `pending`, application: `unaccepted`, @@ -2506,6 +2523,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`preserves every direct handler identity legacy replay coordinate`, () => { + oracleRoute(`case`) const historyKeys = handlerCollectionHistoryGrammar.map( ({ handlerKind, accessPath, legacyOperation }) => `${handlerKind}:${accessPath}:${legacyOperation}`, @@ -2536,6 +2554,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects handler identity wrong-result controls`, () => { + oracleRoute(`calibration`) const history = handlerCollectionHistoryGrammar[0]! const expected = observeHandlerIdentity(history, 2) const mutants: Array = [ @@ -2562,6 +2581,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`resolves a refetch with no tracked Query results`, async () => { + oracleRoute(`case`) const { collection, queryFn } = createOwnershipFixture({ id: `explicit-refetch-empty-aggregate`, results: [], @@ -2588,6 +2608,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`applies a successful on-demand refetch without Query listeners`, async () => { + oracleRoute(`case`) const before = { ...shared, name: `Before` } const after = { ...shared, name: `After` } const { collection } = createOwnershipFixture({ @@ -2635,6 +2656,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`skips a snapshotted Query removed while an earlier refetch starts`, async () => { + oracleRoute(`case`) const queryClient = createQueryClient() const first = { limit: 1 } const second = { limit: 2 } @@ -2697,6 +2719,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`settles a suppressed fetch failure at the fetch boundary while a mutation persists`, async () => { + oracleRoute(`case`) const initial = { ...shared, name: `Initial` } const fetchError = new Error(`Explicit refetch fetch failed`) const queryClient = createQueryClient() @@ -2783,6 +2806,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`waits for every Query result and preserves tracked-key order`, async () => { + oracleRoute(`case`) const firstRefresh = createDeferred>() const secondRefresh = createDeferred>() const refreshedDetail = { ...detailOnly, name: `Detail refreshed` } @@ -2891,6 +2915,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`does not adopt a background application from the same Query`, async () => { + oracleRoute(`case`) const id = `explicit-refetch-excludes-background-application` const queryKey = [id] as const const initial = { ...shared, name: `Initial` } @@ -3002,6 +3027,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects before an independent accepted application settles`, async () => { + oracleRoute(`case`) const id = `explicit-refetch-fail-fast` const detail = { where: eq(new IR.PropRef([`category`]), `detail`) } const list = { where: eq(new IR.PropRef([`category`]), `list`) } @@ -3105,6 +3131,7 @@ describe(`query collection ownership lifecycle`, () => { ])( `settles a $caseName accepted result at fetch and applies it after mutation`, async ({ id, initialName, authoritativeName }) => { + oracleRoute(`case`) const initial = { ...shared, name: initialName } const authoritative = { ...shared, name: authoritativeName } const { collection, queryClient, queryFn } = createOwnershipFixture({ @@ -3187,6 +3214,7 @@ describe(`query collection ownership lifecycle`, () => { ) it(`settles an eager refetch at fetch while another collection refetches`, async () => { + oracleRoute(`case`) const queryClient = createQueryClient() const concurrentResult = createDeferred>() const explicitResult = createDeferred>() @@ -3308,6 +3336,7 @@ describe(`query collection ownership lifecycle`, () => { )( `settles an external refetch at fetch with $schedule overlap (legacy replay: $legacyOperation)`, async ({ legacyOperation, schedule }) => { + oracleRoute(`case`) const initial = { ...shared, name: `Initial` } const authoritative = { ...shared, name: `Authoritative` } const refreshResult = createDeferred>() @@ -3424,6 +3453,7 @@ describe(`query collection ownership lifecycle`, () => { )( `settles an external refetch when mutation starts during persisted application preparation (legacy replay: $legacyOperation)`, async ({ legacyOperation }) => { + oracleRoute(`case`) const id = `external-${legacyOperation}-persisted-application-overlap` const queryHash = hashKey([id]) const initial = { ...shared, name: `Initial` } @@ -3533,6 +3563,7 @@ describe(`query collection ownership lifecycle`, () => { ) it(`rejects a call-entry-only boundary mutant for refresh-first overlap`, () => { + oracleRoute(`calibration`) const history = mutationRefreshHistoryGrammar.find( ({ invocation, legacyOperation, schedule }) => invocation === `external-handler-overlap` && @@ -3565,6 +3596,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`preserves every mutation refresh legacy replay coordinate`, () => { + oracleRoute(`case`) expect(mutationRefreshHistoryGrammar).toHaveLength(12) expect( new Set( @@ -3592,6 +3624,7 @@ describe(`query collection ownership lifecycle`, () => { )( `settles a $invocation refetch at the fetch boundary (legacy replay: $legacyOperation)`, async ({ invocation, legacyOperation }) => { + oracleRoute(`case`) const id = `mutation-refresh-${invocation}-${legacyOperation}` const initial = { ...shared, name: `Initial` } const authoritative = { ...shared, name: `Authoritative` } @@ -3702,6 +3735,7 @@ describe(`query collection ownership lifecycle`, () => { )( `settles a captured handler Collection refetch at the fetch boundary (legacy replay: $legacyOperation)`, async ({ legacyOperation }) => { + oracleRoute(`case`) const id = `captured-handler-refresh-${legacyOperation}` const initial = { ...shared, name: `Initial` } const authoritative = { ...shared, name: `Authoritative` } @@ -3795,6 +3829,7 @@ describe(`query collection ownership lifecycle`, () => { ) it(`does not schedule Collection work for a foreign Query`, async () => { + oracleRoute(`case`) const queryClient = createQueryClient() const baselineClient = createQueryClient() const first = createCollection( @@ -3863,6 +3898,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`does not defer reverted fetch record retirement to a successor fetch`, async () => { + oracleRoute(`case`) const measureSuccessorFetchMicrotasks = async ( cancelFirst: boolean, ): Promise => { @@ -3937,6 +3973,7 @@ describe(`query collection ownership lifecycle`, () => { it.each(handlerCollectionHistoryGrammar)( `preserves $handlerKind handler identity through $accessPath refetch (legacy replay: $legacyOperation)`, async (history) => { + oracleRoute(`case`) const { handlerKind, accessPath, legacyOperation } = history const initial = [ { id: `first`, category: `shared`, name: `First` }, @@ -4143,6 +4180,7 @@ describe(`query collection ownership lifecycle`, () => { ])( `settles an application failure with throwOnError=$throwOnError`, async ({ throwOnError, expectedOutcome }) => { + oracleRoute(`case`) const initial = { ...shared, name: `Initial` } const invalid = { ...shared, name: `Invalid` } const applicationError = new Error(`Explicit refetch application failed`) @@ -4220,6 +4258,7 @@ describe(`query collection ownership lifecycle`, () => { ) it(`records a handler-phase application failure after fetch settlement`, async () => { + oracleRoute(`case`) const initial = { ...shared, name: `Initial` } const invalid = { ...shared, name: `Invalid` } const applicationError = new Error(`Handler application failed`) @@ -4292,6 +4331,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects silent fulfillment in the public result-settlement checker`, () => { + oracleRoute(`calibration`) const deferredOperation = `deferred` const deferred = advanceResultSettlementModel( startResultSettlementModel(deferredOperation), @@ -4358,6 +4398,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`fulfills a public refetch after applying a valid empty result`, async () => { + oracleRoute(`case`) const id = `empty-result-settlement` const queryClient = createQueryClient() const queryFn = vi @@ -4403,6 +4444,7 @@ describe(`query collection ownership lifecycle`, () => { it.each([`fulfilled`, `rejected`] as const)( `keeps a same-result reentrant $receiptOutcome receipt attached to its refetch`, async (receiptOutcome) => { + oracleRoute(`case`) const id = `same-result-reentrant-application` const initial = { ...shared, name: `Initial` } const updated = { ...shared, name: `Updated` } @@ -4581,6 +4623,7 @@ describe(`query collection ownership lifecycle`, () => { ] as const)( `rejects a public refetch whose successful Query result has an invalid $invalidShape shape, throwOnError=$throwOnError`, async ({ throwOnError, invalidResult, diagnostic }) => { + oracleRoute(`case`) const id = `invalid-result-settlement` const queryClient = createQueryClient() const queryFn = vi @@ -4647,6 +4690,7 @@ describe(`query collection ownership lifecycle`, () => { ) it(`keeps a deferred public refetch pending until its replacement applies`, async () => { + oracleRoute(`case`) const id = `deferred-result-settlement` const barrier = createDeferred() const initial = { ...shared, name: `Initial` } @@ -4724,6 +4768,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`cancels a deferred public refetch when cleanup retires it`, async () => { + oracleRoute(`case`) const id = `deferred-result-cleanup` const barrier = createDeferred() const queryClient = createQueryClient() @@ -4781,6 +4826,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`does not reuse an invalid result rejection for a later valid refetch`, async () => { + oracleRoute(`case`) const id = `recovered-result-settlement` const queryHash = hashKey([id]) const persistedScan = @@ -4849,6 +4895,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`does not reuse an eager initial rejection when a refetch applies a valid result`, async () => { + oracleRoute(`case`) const id = `eager-recovered-result-settlement` const invalidResult = 42 as unknown as Array const recovered = { ...shared, name: `Recovered` } @@ -4921,6 +4968,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects an invalid retained revalidation before its persisted baseline loads`, async () => { + oracleRoute(`case`) const id = `retained-invalid-result-settlement` const queryHash = hashKey([id]) const persistedScan = @@ -4987,6 +5035,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`keeps refetches pending when an older barrier refresh reaches a newer barrier`, async () => { + oracleRoute(`case`) const id = `overlapping-defer-generations` const barrierA = createDeferred() const barrierB = createDeferred() @@ -5123,6 +5172,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`settles a fresh applied refetch independently of an older deferred result`, async () => { + oracleRoute(`case`) const id = `independent-refetch-settlement` const barrier = createDeferred() const initial = { ...shared, name: `Initial` } @@ -5224,6 +5274,7 @@ describe(`query collection ownership lifecycle`, () => { it.each(reacquisitionHistories)( `keeps a reacquired lease's result (staleTime $staleTime, refetch $refetch, settles $settles, peers $peers, rows $rows)`, async (history) => { + oracleRoute(`case`) const id = `reacquired-lease-${history.staleTime}-${history.refetch}-${history.settles}-${history.peers}-${history.rows}` const detail = categorySubset(`detail`) const peerSubsets = [ @@ -5336,6 +5387,7 @@ describe(`query collection ownership lifecycle`, () => { it.each(publishedOwnerHistories)( `publishes only active owners when a lease claims a released row (claims $claims, peer $peer, reacquired $reacquired)`, async (history) => { + oracleRoute(`case`) const id = `published-owners-${history.claims}-${history.peer}-${history.reacquired}` const subset = (category: string): LoadSubsetOptions => ({ where: new IR.Func(`in`, [ @@ -5480,6 +5532,7 @@ describe(`query collection ownership lifecycle`, () => { ])( `keeps server rows a direct write did not touch when an older fetch settles: %s`, async (cut) => { + oracleRoute(`case`) type Row = { id: string; value: string } const id = `older-fetch-merge-${cut}` let server: Array = @@ -5621,7 +5674,7 @@ describe(`query collection ownership lifecycle`, () => { * those indices to the documented static on-demand Query key for metadata * comparison. It compares public rows and exact accepted owner identities * after each action and release; forgetting ownership on any write fails the - * update lane, while retaining a deleted lifetime fails the reinsert lane. + * update grammar, while retaining a deleted lifetime fails the reinsert grammar. */ it.each( ([1, 2] as const).flatMap((ownerCount) => @@ -5639,7 +5692,10 @@ describe(`query collection ownership lifecycle`, () => { ), )( `tracks a direct row lifetime through $history with $ownerCount owners and metadata=$withMetadata`, - runDirectRowLifetimeHistory, + (history) => { + oracleRoute(`case`) + return runDirectRowLifetimeHistory(history) + }, ) /** @@ -5657,7 +5713,7 @@ describe(`query collection ownership lifecycle`, () => { * and subset release it compares public rows with the reference. A fetched * cached result needs no request; a local cache edit with no later fetch * requires one to establish authority. The inverse order rejects blindly - * trusting any cached result; the cache-edit lanes reject mistaking + * trusting any cached result; the cache-edit grammars reject mistaking * setQueryData for a remote success. */ it.each( @@ -5675,7 +5731,10 @@ describe(`query collection ownership lifecycle`, () => { ), )( `orders shared fetches, writes, and cache edits before acquisition: $order/$laterWrite/cache=$cacheEdit/refetch=$finalRefetch`, - runDirectWriteOrderHistory, + (history) => { + oracleRoute(`case`) + return runDirectWriteOrderHistory(history) + }, ) /** @@ -5725,10 +5784,14 @@ describe(`query collection ownership lifecycle`, () => { }, ])( `orders overlapping subset values and owners: existing=$existingOwner/newer-first=$settleNewerFirst/write=$directWrite/equal=$sameValue`, - runOverlappingSubsetHistory, + (history) => { + oracleRoute(`case`) + return runOverlappingSubsetHistory(history) + }, ) it(`does not rescan a stable empty persisted baseline on retained reacquisition`, async () => { + oracleRoute(`case`) const id = `empty-persisted-baseline-work` const queryHash = hashKey([id]) const scanPersistedRows = vi.fn(() => Promise.resolve([])) @@ -5765,6 +5828,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`scans a cold durable owner that appeared after an empty sync run`, async () => { + oracleRoute(`case`) const id = `cold-owner-after-empty-baseline` const queryHash = hashKey([id]) const hotStorage = createOwnershipStorage() @@ -5801,6 +5865,7 @@ describe(`query collection ownership lifecycle`, () => { }) it(`rejects a superseded accepted result while publishing its successor`, async () => { + oracleRoute(`case`) const id = `superseded-retained-scan` const queryKey = [id] as const const queryHash = hashKey(queryKey) diff --git a/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts b/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts index e832509f60..8f842e1f2c 100644 --- a/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts +++ b/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts @@ -3,6 +3,7 @@ import { DuplicateKeySyncError, createCollection } from '@tanstack/db' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { queryCollectionOptions } from '../src/query' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config.js' import { checked, createAdapter, @@ -33,7 +34,7 @@ import type { Op, Row, StorageKind } from './persisted-direct-write-support' * the write runs. The reference does not share the persistence wrapper, which * is the code under judgment. It shares the Query Collection's direct-write * code, so it cannot find a fault in that code that both paths share. The - * precedence rules for writes that interleave with refetches are pinned as + * precedence rules for writes that interleave with refetches are fixed as * unit tests in `persisted-direct-write.test.ts`; they are not an oracle. * * ## Contract and ownership table @@ -292,6 +293,7 @@ async function compareWriteWithReference( } declareOracle({ + owns: [`src/query.ts#queryCollectionOptions`], check: [ compareWriteWithReference, compareLaterRefetchOrder, @@ -304,6 +306,7 @@ describe(`persisted direct writes behind a waiting refetch`, () => { for (const keyCase of keyCases) { for (const op of ops) { it(`${op} with ${holder} holding the lock, key ${keyCase}`, async () => { + oracleRoute(`case`) await compareWriteWithReference(op, holder, keyCase) }) } @@ -513,6 +516,7 @@ describe(`a later refetch while a persisted direct write waits`, () => { for (const start of [`before`, `after`] as const) { for (const touch of [`same`, `different`] as const) { it(`R3 started ${start} the write, ${touch} key`, async () => { + oracleRoute(`case`) await compareLaterRefetchOrder(start, touch) }) } @@ -531,6 +535,7 @@ describe(`a later refetch while a persisted direct write waits`, () => { describe(`real SQLite: persisted direct writes behind a waiting refetch`, () => { for (const op of ops) { it(`${op} with source-write holding the lock, key refetch-only`, async () => { + oracleRoute(`case`) await compareWriteWithReference( op, `source-write`, @@ -542,6 +547,7 @@ describe(`real SQLite: persisted direct writes behind a waiting refetch`, () => for (const start of [`before`, `after`] as const) { for (const touch of [`same`, `different`] as const) { it(`R3 started ${start} the write, ${touch} key`, async () => { + oracleRoute(`case`) await compareLaterRefetchOrder(start, touch, `sqlite`) }) } @@ -612,6 +618,7 @@ async function compareDuplicateInsert(): Promise { describe(`a persisted direct insert of an existing key`, () => { it(`rejects like the reference and keeps the row`, async () => { + oracleRoute(`fixed history`) await compareDuplicateInsert() }) }) diff --git a/packages/query-db-collection/tests/persisted-direct-write.test.ts b/packages/query-db-collection/tests/persisted-direct-write.test.ts index c2cd2fb983..2903a2c58b 100644 --- a/packages/query-db-collection/tests/persisted-direct-write.test.ts +++ b/packages/query-db-collection/tests/persisted-direct-write.test.ts @@ -1,5 +1,5 @@ // Ordinary unit tests for direct writes that interleave with refetches, and for -// the oracle harness's failure reporting. Their expected rows are pinned by +// the oracle harness's failure reporting. Their expected rows are fixed by // hand from the precedence rules in each test's comments. The matrix and ordering // laws are owned by persisted-direct-write-window.oracle.test.ts. import { describe, expect, it, vi } from 'vitest' @@ -65,7 +65,7 @@ describe(`the oracle harness keeps the primary failure`, () => { * stores nothing. * * Each case below states its expected rows from these rules. The histories - * are finite and pinned. Rule 4 is observed inside the refetch's fulfillment + * are finite and fixed. Rule 4 is observed inside the refetch's fulfillment * callback, not after a later flush, because a later flush hides a refetch * that settles early. */ diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 33647437aa..b7f18255be 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -508,7 +508,7 @@ describe(`Query collection type resolution tests`, () => { * types at compile time. * * Observations: `select` input, output-row keys, delete keys, collection - * reads, and mutation inputs. The paired configs are the hostile control: + * reads, and mutation inputs. The paired configs are the mutant control: * they differ only in whether `select` returns schema output or input. * Runtime schema parsing and result publication are outside this oracle. */ diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 3bfc49436c..e29328cb32 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -11527,7 +11527,7 @@ describe(`QueryCollection`, () => { * A reload runs the Query Collection's `sync` again while the Collection still * shows the previous run's status. The new run's first successful result makes * it ready, and a failure before that fails the reload, whatever status the - * Collection showed when the reload began. The lane crosses the status before + * Collection showed when the reload began. This grammar crosses the status before * the reload (a failed first load, or ready) with the new run's outcome. */ describe(`reload settles on the new run's first result`, () => { @@ -11861,7 +11861,7 @@ describe(`reload settles on the new run's first result`, () => { * Checkpoint: the driver observes the window when the entry is gone (the * housekeeping commit has applied), polling rather than sleeping a fixed * time, so a loaded runner cannot move the observation before the expiry. - * A housekeeping begin marked `snapshot: true` is the hostile mutant: it + * A housekeeping begin marked `snapshot: true` is the mutant: it * replaces the kept rows with none. * * A third kind, `release-at-startup`, reaches the other housekeeping @@ -11872,7 +11872,7 @@ describe(`reload settles on the new run's first result`, () => { * Limits: releasing a Query's rows (`cleanupQueryInternal`) begins a * housekeeping transaction too, but no legal history begins it in the new * run while the reload is in flight, so a snapshot there is an equivalent - * mutant for this lane. Its callers are the run's own + * mutant for this grammar. Its callers are the run's own * cleanup and the fallback of a subset release. Core invokes a run's * cleanup (on cleanup, on a reload's retirement, or after sync entry) only * after it has retired that run's callbacks, so that begin does nothing. @@ -12078,7 +12078,7 @@ describe(`reload settles on the new run's first result`, () => { * run starts and gives that run startup maintenance. During the reload the * driver requests and releases a fresh subset, through a public change * subscription, in every microtask turn from `reload()` until the new - * run's `markReady()`. So the lane covers every turn of the window, + * run's `markReady()`. So the grammar covers every turn of the window, * whatever its width. The refetch history makes the same requests and * releases for the same number of turns. * @@ -12328,7 +12328,7 @@ describe(`reload settles on the new run's first result`, () => { * The model keeps each failing subset's latest error value and the listed * values, oldest first. A failure lists its value at the end unless it is * already listed. After every step, a listed value that no failing subset - * still holds leaves the list. The lane loads two subsets and enumerates + * still holds leaves the list. The grammar loads two subsets and enumerates * every history of four steps, each step a fetch of one subset's Query that * succeeds, fails with a fresh error, or fails with one error value shared by * every Query and fetch. After each step the node's `failureReason.errors` @@ -12479,7 +12479,7 @@ describe(`failureReason follows the still-failing Queries`, () => { thrownBefore + 1, ) const error = fails ? thrown.at(-1) : undefined - // The persisted run replays the plain lane's histories, whose reach + // The persisted production path replays the plain path's histories, whose reach // the plain run records. if (!persisted) recordReach(model, fetch, error) const previous = model.listed 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 cf1801237a..7488260f45 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 @@ -12,8 +12,11 @@ import fc from 'fast-check' import { describe, expect, it, onTestFailed, vi } from 'vitest' import { createOracleReach } from '../../db/tests/oracle-reach.js' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, + oracleRoute, + oracleRouteOf, oracleRuns, readOracleRunConfig, } from '../../db/tests/oracle-config.js' @@ -112,15 +115,15 @@ import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' * that honors only `offset` already gets a wrong window at preload under a * joined filter: the loader repeats a continuation offset capped at the * window size. That loader defect is separate from #2136 and is recorded as - * open in the coverage map. The named replays still run both encodings. + * open in the coverage map. The named fixed histories still run both encodings. * - It does not check the window after a marker flip, even when the provider * is current. The loader used to stop refilling after a joined-side - * removal; a named replay now checks that trace, but the campaigns do not + * removal; a named fixed history now checks that trace, but the campaigns do not * check after flips. * - The generated campaigns use an uncapped provider. With a provider that * caps pages, a row whose order changes can drop out of the window after a * refetch even on main, through the loader's own repair. That defect is - * separate from #2136 and has an `it.fails` witness. The named replays run + * separate from #2136 and has an `it.fails` witness. The named fixed histories run * the issue trace with a page cap. * - The report laws bound when Query DB reports, not exactly when it must. * Whether a missing report leaves a gap is judged through the window law. @@ -132,7 +135,7 @@ import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' * accepted, so a later successful retry still sees the change. No public * history distinguishes that from recording earlier: the failed attempt * reports on its own, and the repair it starts reads the provider again. - * The application-failure replay checks only the window around a failed + * The application-failure fixed history checks only the window around a failed * application. * - A Query refetch that joins a running `refetch: true` acquisition on the * same Query hash does not report, because the guard cannot tell the two @@ -252,7 +255,7 @@ function expectedCoverage( // write from its start, or from just before its refetch, and releases it // after the refetch. Ids come from a small space, so // inserts often land between served pages and upserts often move a served -// row. Named replays use the same commands in other orders. With one order +// row. Named fixed histories use the same commands in other orders. With one order // term, the fixture maps a generated rank to `rank * 100 + id`, so ranks stay // unique. type Command = @@ -307,7 +310,7 @@ const caseArbitrary: fc.Arbitrary = fc.record({ functionKey: fc.boolean(), terms: fc.constantFrom(1 as const, 2 as const), orderKey: fc.constantFrom(`rank` as const, `label` as const), - // Page caps run in named replays only; see the limits above. + // Page caps run in named fixed histories only; see the limits above. pageCap: fc.constant(undefined), limit: fc.integer({ min: 1, max: 3 }), offset: fc.integer({ min: 0, max: 1 }), @@ -1229,7 +1232,10 @@ const campaigns = ? ([`replay`] as const) : ([] as const) +// What this oracle owns: the on-demand Query collection's refetch and the ordered +// prefix it serves (queryCollectionOptions and the loader it builds). declareOracle({ + owns: [`src/query.ts#queryCollectionOptions`], check: [checkCase], model: [expectedWindow, expectedCoverage], grammar: [caseArbitrary, heldCaseArbitrary, sameKeyEndCaseArbitrary], @@ -1243,6 +1249,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `shows a row that moved between two served pages after a refetch (%s encoding)`, async (encoding) => { + oracleRoute(`case`) const observation = await checkCase({ scenario: { encoding, @@ -1275,6 +1282,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `loads a row that moved between two served pages before it joins the window (%s encoding)`, async (encoding) => { + oracleRoute(`case`) const observation = await checkCase({ scenario: { encoding, @@ -1301,6 +1309,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `shows a moved row after a refetch when the provider caps pages (%s encoding)`, async (encoding) => { + oracleRoute(`case`) const observation = await checkCase({ scenario: { encoding, @@ -1329,6 +1338,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `shows a moved row when another live query loaded the page's new row first (%s encoding)`, async (encoding) => { + oracleRoute(`case`) const observation = await checkCase({ scenario: { encoding, @@ -1359,6 +1369,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `repairs when a page keeps its keys but its last row moves (%s encoding)`, async (encoding) => { + oracleRoute(`case`) await checkCase({ scenario: { encoding, @@ -1395,6 +1406,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `shows a row that ties at a page's new, earlier end (%s encoding)`, async (encoding) => { + oracleRoute(`case`) await checkCase({ scenario: { encoding, @@ -1426,6 +1438,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // new end and the loader's old boundary and is in neither result. // (Found by the random campaign.) it(`shows a row that lands between a filtered page's new and old end`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -1455,6 +1468,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // apply, unmarked row 4 heads the page and row 3 sorts between it and // row 0; only a repair after the rows become visible can load row 3. it(`shows a row that lands between a page's new and old end while an optimistic update persists`, async () => { + oracleRoute(`case`) const scenario: Scenario = { encoding: `cursor`, functionKey: false, @@ -1503,6 +1517,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([1, 2] as const)( `a refetch that moves a first page's end proves the window again with bounded reads (%i order terms)`, async (terms) => { + oracleRoute(`case`) const scenario: Scenario = { encoding: `cursor`, functionKey: false, @@ -1551,6 +1566,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // continuation still starts where the first page used to end, so a row // that now sorts between them is in neither result. it(`shows a row that moved before an empty later page`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -1583,6 +1599,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `shows a row that moved between two served pages after a Query invalidation (%s encoding)`, async (encoding) => { + oracleRoute(`case`) const observation = await checkCase({ scenario: { encoding, @@ -1612,6 +1629,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `a second refetch still covers the first served boundary (%s encoding)`, async (encoding) => { + oracleRoute(`case`) const observation = await checkCase({ scenario: { encoding, @@ -1665,6 +1683,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => history: [{ type: `delete` as const, id: 2 }], }, ])(`a refetch does not report when $name`, async ({ initial, history }) => { + oracleRoute(`case`) const observation = await checkCase({ scenario: { encoding: `cursor`, @@ -1685,6 +1704,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // that page's rows do not apply. The window then shows its last result or // the right one, and the next refetch must restore it. it(`a refetch whose page application fails keeps the last window until the next refetch`, async () => { + oracleRoute(`case`) const scenario: Scenario = { encoding: `cursor`, functionKey: false, @@ -1726,6 +1746,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => }) it(`a refetch settles when the provider honors neither cursor nor offset`, async () => { + oracleRoute(`case`) const run = await startRun({ scenario: { encoding: `cursor`, @@ -1761,6 +1782,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.each([`cursor`, `offset`] as const)( `keeps the last window when the repair fails, and the retry restores it (%s encoding)`, async (encoding) => { + oracleRoute(`case`) const testCase: Case = { scenario: { encoding, @@ -1831,6 +1853,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.fails( `an offset-only provider fills a joined window at preload (known loader defect)`, async () => { + oracleRoute(`case`) const run = await startRun({ scenario: { encoding: `offset`, @@ -1861,6 +1884,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // sent, which discarded the repair's boundary, and paging restarted from // the start of the order. It now passes. it(`a current provider refills a joined window after a marker removal`, async () => { + oracleRoute(`case`) const testCase: Case = { scenario: { encoding: `cursor`, @@ -1907,6 +1931,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.fails( `a capped provider keeps a moved row in the window after a refetch (known loader defect)`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -1943,6 +1968,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // new, smaller first page that could not retire the repair's prefix, and // every later refetch of an unchanged backend read both. it(`a repair that releases another request's row keeps one first page`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -1980,6 +2006,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // each such answer: if it stops, row 0 is never loaded, although a short // window treats every row as read. Only the coverage law sees that. it(`a short window keeps paging through Queries answered from the cache`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -2023,6 +2050,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // reverted; the fix that resumes a continuation after a synchronous answer // closed it. The generated grammar now holds deletes too. it(`a refetch during a held optimistic delete keeps the window right after release`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -2059,6 +2087,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // is unchanged: after release, the ordered window equals the pure backend // window, regardless of which consumer previously observed a deleted row. it(`a held refetch removes a deleted row owned by an observed query`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -2093,6 +2122,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // This is the exact reduced CI history. It has no separate expected // answer: both the window and latent coverage use the shared model. it(`keeps coverage when a held page keeps its key and moves earlier`, async () => { + oracleRoute(`case`) await checkCase({ scenario: { encoding: `cursor`, @@ -2138,7 +2168,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => ) const options = campaign === `fixed` - ? { seed: 20261102, numRuns: oracleRuns(24) } + ? oracleFixedSeed(20261102, { numRuns: oracleRuns(24) }) : oraclePropertyOptions(24, endProperty) const seed = options.seed ?? Math.floor(Math.random() * 2 ** 31) onTestFailed(() => { @@ -2146,6 +2176,8 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => `${endProperty} ${campaign} campaign: rerun with TANSTACK_DB_ORACLE_SEED=${seed}`, ) }) + const parameters = oracleFixedSeed(seed, { ...options }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(sameKeyEndCaseArbitrary, async (testCase) => { reach.beginHistory() @@ -2155,7 +2187,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => if (observation.reachedDeleteHeldGapReportAndRepair) reach.reach(`delete-held gap reported and repaired`) }), - { ...options, seed }, + parameters, ) reach.assertReached() console.info(endProperty, campaign, Object.fromEntries(reach.counts())) @@ -2168,6 +2200,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => it.skipIf(oracleReplayRequested())( `same-key boundary shifts preserve coverage across widths and hold regimes`, async () => { + oracleRoute(`case`) const states = [1, 2, 3].flatMap((limit) => [0, 1].map( (offset) => `delete-held gap width ${limit} offset ${offset}`, @@ -2241,12 +2274,14 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => `generated refetch histories under a hold (fixed campaign)`, async () => { let reachedReport = 0 + const parameters = oracleFixedSeed(20261015, { numRuns: oracleRuns(40) }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(heldCaseArbitrary, async (testCase) => { const observation = await withDeadline(checkCase(testCase)) if (observation.reachedReportDuringHold) reachedReport++ }), - { seed: 20261015, numRuns: oracleRuns(40) }, + parameters, ) expect(reachedReport).toBeGreaterThan(0) }, @@ -2268,7 +2303,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => const reach = createOracleReach(property, states, { minimum: 3 }) const options = campaign === `fixed` - ? { seed: 20261013, numRuns: oracleRuns(80) } + ? oracleFixedSeed(20261013, { numRuns: oracleRuns(80) }) : oraclePropertyOptions(80, property) // fast-check reports its seed only when the property fails. Choose the // random campaign's seed here, so a test timeout can report it too. @@ -2278,6 +2313,8 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => `${property} ${campaign} campaign: rerun with TANSTACK_DB_ORACLE_SEED=${seed}`, ) }) + const campaignParameters = oracleFixedSeed(seed, { ...options }) + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(caseArbitrary, async (testCase) => { reach.beginHistory() @@ -2286,7 +2323,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => if (observation[state]) reach.reach(state) } }), - { ...options, seed }, + campaignParameters, ) reach.assertReached() console.info(property, campaign, Object.fromEntries(reach.counts())) diff --git a/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts b/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts index b078d92464..672254b040 100644 --- a/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts +++ b/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts @@ -10,6 +10,7 @@ import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' import { describe, expect, it } from 'vitest' import { queryCollectionOptions } from '../src/query.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' +import { oracleRoute } from '../../db/tests/oracle-config.js' import type { DehydratedState } from '@tanstack/query-core' import type { LoadSubsetOptions } from '@tanstack/db' @@ -118,7 +119,12 @@ function expectHydratedRow( browserClient.clear() } -declareOracle({ check: [expectHydratedRow] }) +// What this oracle owns: the Query cache entries that queryCollectionOptions +// creates and loads, which the SSR dehydration and stream checks observe. +declareOracle({ + owns: [`src/query.ts#queryCollectionOptions`], + check: [expectHydratedRow], +}) async function checkWithCleanup( check: () => Promise, @@ -157,6 +163,7 @@ async function checkWithCleanup( describe(`Query collection SSR dehydration oracle`, () => { it(`serializes an ordinary successful Query cache entry`, async () => { + oracleRoute(`case`) const queryClient = createQueryClient() await checkWithCleanup(async () => { queryClient.setQueryData([`plain-ssr-control`], [row]) @@ -168,6 +175,7 @@ describe(`Query collection SSR dehydration oracle`, () => { }) it(`serializes a successful on-demand entry with a compound predicate`, async () => { + oracleRoute(`case`) const queryClient = createQueryClient() let receivedWhere: LoadSubsetOptions[`where`] const collection = createCollection( @@ -211,6 +219,7 @@ describe(`Query collection SSR dehydration oracle`, () => { }) it(`serializes a successful on-demand entry with a cancellation signal`, async () => { + oracleRoute(`case`) const queryClient = createQueryClient() const requestSignal = new AbortController().signal let receivedSignal: AbortSignal | undefined @@ -242,6 +251,7 @@ describe(`Query collection SSR dehydration oracle`, () => { }) it(`serializes an ordered cursor request with a custom comparator`, async () => { + oracleRoute(`case`) const queryClient = createQueryClient() const compare = (a: string, b: string): number => a.localeCompare(b) const orderBy: NonNullable = [ diff --git a/packages/react-db/tests/bound-schema-options-oracle.test.tsx b/packages/react-db/tests/bound-schema-options-oracle.test.tsx index 322f90ee3c..95a22f5c8f 100644 --- a/packages/react-db/tests/bound-schema-options-oracle.test.tsx +++ b/packages/react-db/tests/bound-schema-options-oracle.test.tsx @@ -10,6 +10,7 @@ import { afterAll, expect, test } from 'vitest' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { boundSchemaHistories, @@ -46,6 +47,7 @@ for (const hook of [`live`, `suspense`] as const) { test.each(boundSchemaHistories)( `React ${hook} binds mutation schemas %j`, async (history) => { + oracleRoute(`case`) ran = true const host: BoundSchemaNativeHost = { mount: (input) => { @@ -101,6 +103,7 @@ function observationFromModel( test.each(boundSchemaHistories)( `schema judgment rejects wrong binding and mutation observations %j`, (history) => { + oracleRoute(`calibration`) const base = [ { id: `one`, label: ` untouched first `, rank: 1 }, { id: `peer`, label: ` untouched peer `, rank: 2 }, @@ -253,6 +256,10 @@ test.each(boundSchemaHistories)( }, ) declareOracle({ + owns: [ + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], grammar: [boundSchemaHistories], check: [ checkBoundSchemaMutation, diff --git a/packages/react-db/tests/cache-first-registration-oracle.test.tsx b/packages/react-db/tests/cache-first-registration-oracle.test.tsx index 2aca20f25e..f2c654d24e 100644 --- a/packages/react-db/tests/cache-first-registration-oracle.test.tsx +++ b/packages/react-db/tests/cache-first-registration-oracle.test.tsx @@ -38,6 +38,7 @@ import { } from '../../db/tests/live-query-cache-options-oracle' import { createOracleReach } from '../../db/tests/oracle-reach' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { withOracleCleanup } from '../../db/tests/utils' import type { CacheRow } from '../../db/tests/live-query-cache-options-oracle' @@ -60,6 +61,7 @@ test.each( )( `first React registration without transported result: %j`, async ({ history, firstReader, secondReader, held }) => { + oracleRoute(`case`) const reach = createOracleReach( `React first registration ${firstReader}/${secondReader}`, [ @@ -339,6 +341,11 @@ test.each( ) declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], check: [ checkCacheOptionAdmission, checkCacheOptionPromise, diff --git a/packages/react-db/tests/cache-options-oracle.test.tsx b/packages/react-db/tests/cache-options-oracle.test.tsx index 105e4003c9..6fdcb32e1c 100644 --- a/packages/react-db/tests/cache-options-oracle.test.tsx +++ b/packages/react-db/tests/cache-options-oracle.test.tsx @@ -27,11 +27,17 @@ import { conflictingCacheOptions, } from '../../db/tests/live-query-cache-options-oracle' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { withOracleCleanup } from '../../db/tests/utils' import type { CacheRow } from '../../db/tests/live-query-cache-options-oracle' declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], check: [checkCacheOptionAdmission, checkCacheOptionRows], model: [conflictingCacheOptions], grammar: [cacheOptionHistories], @@ -68,6 +74,7 @@ test.each( paths.map((path) => ({ history, path })), ), )(`React cache admission %j`, async ({ history, path }) => { + oracleRoute(`case`) const reach = reaches[path]! reach.beginHistory() const client = new DbClient() diff --git a/packages/react-db/tests/client-window-oracle.test.tsx b/packages/react-db/tests/client-window-oracle.test.tsx index 3496ca8ca1..952d29c8e8 100644 --- a/packages/react-db/tests/client-window-oracle.test.tsx +++ b/packages/react-db/tests/client-window-oracle.test.tsx @@ -38,8 +38,11 @@ import { createOracleReach } from '../../db/tests/oracle-reach' import { createOracleFailureFidelity } from '../../db/tests/oracle-failure-fidelity' import { declareOracle, + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { @@ -403,17 +406,16 @@ describe(`client page identity`, () => { }, ) try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { + numRuns: oracleRuns(6) + examples.length, + examples, + }) + : oraclePropertyOptions(6, property, { examples }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => - fc.assert( - propertyUnderTest, - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + examples.length, - seed: 2345, - examples, - } - : oraclePropertyOptions(6, property, { examples }), - ), + fc.assert(propertyUnderTest, parameters), ) } finally { console.info( @@ -426,6 +428,11 @@ describe(`client page identity`, () => { } }) declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], grammar: [clientWindowArbitrary], check: [checkClientWindowHistory, checkClientSwitch], }) diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index c98fd21844..5e41d7934d 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -395,7 +395,7 @@ const reactDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: true, suspense: true, pooledEqFilters: true }, } diff --git a/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx index 214abb38d6..ec46b9ba78 100644 --- a/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx +++ b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx @@ -12,10 +12,13 @@ import { expectedLazyRows, } from '../../convex-db-collection/tests/lazy-paging-model-oracle' import { withTestCleanup } from '../../convex-db-collection/tests/helpers' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' it.each([1, 2, 3, 4])( `receives exact infinite-query pages with provider page size %s`, async (pageSize) => { + oracleRoute(`case`) const rows = Array.from({ length: 7 }, (_, i) => ({ id: `r${i}`, rank: Math.floor(i / 2), @@ -115,3 +118,8 @@ it.each([1, 2, 3, 4])( ) }, ) + +declareOracle({ + owns: [`src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`], + model: [expectedLazyRows], +}) diff --git a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx index aaf89d4108..177347b5a5 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -40,7 +40,7 @@ * claims its local source objects while the browser source is still loading; * a later same-hash preload over another object must reject. The model expects * the server row before browser readiness and the browser row after handoff. - * A hostile observer that omits the committed claim fails at the conflicting + * A mutant observer that omits the committed claim fails at the conflicting * preload assertion while the source remains unready. * A Suspense read is itself a preload, even if React abandons its render. With * a pending hydrated stream, that first local preload starts the source and @@ -113,7 +113,13 @@ import { ssrSnapshotHistoryArbitrary, } from '../../db/tests/ssr-snapshot-admission-oracle' import { ssrStreamHydrationMismatches } from '../../db/tests/ssr-stream-view-oracle' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { DbProvider } from '../src/DbProvider' @@ -176,6 +182,12 @@ function applyModelWrite(model: Map, next: Row): void { } declareOracle({ + owns: [ + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], check: [checkStreamCut], model: [expectedRows, applyModelWrite], }) @@ -214,6 +226,7 @@ describe(`standalone descriptor query binding`, () => { )( `$reader after an abandoned render (startSync=$startSync, $syncMode, cleanup=$cleanupAt, pending=$cleanupPending)`, async (history) => { + oracleRoute(`case`) const { reader, startSync, syncMode, cleanupAt, cleanupPending } = history const expected = modelSubscriberStartCleanup(history) const pendingCleanup = createDeferred() @@ -352,6 +365,7 @@ describe(`standalone descriptor query binding`, () => { it.each(histories)( `binds one prebuilt Query to each receiving DbClient with $name`, async ({ name, initial, writes }) => { + oracleRoute(`case`) const descriptor = collectionOptions( `standalone-descriptor-rows-${name}`, (client) => @@ -516,6 +530,21 @@ describe(`descriptor error handoff at React commit`, () => { `throw-during-release`, `undefined-cause`, ]) + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(30) }) + : oraclePropertyOptions(30, properties[observation], { + examples: ([`fail`, `throw`] as const).flatMap((outcome) => + ([`error`, `undefined`] as const).flatMap((cause) => + ([`before-release`, `during-release`] as const).map( + (timing): [ErrorHandoffHistory] => [ + { outcome, cause, timing }, + ], + ), + ), + ), + }) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc.record({ @@ -649,19 +678,7 @@ describe(`descriptor error handoff at React commit`, () => { // The random campaign runs every one of the eight histories first, // so its reach does not depend on the seed: 30 biased draws over // eight histories can miss one. - campaign === `fixed` - ? { numRuns: oracleRuns(30), seed: 2229 } - : oraclePropertyOptions(30, properties[observation], { - examples: ([`fail`, `throw`] as const).flatMap((outcome) => - ([`error`, `undefined`] as const).flatMap((cause) => - ([`before-release`, `during-release`] as const).map( - (timing): [ErrorHandoffHistory] => [ - { outcome, cause, timing }, - ], - ), - ), - ), - }), + parameters, ) reach.assertReached() console.info( @@ -949,22 +966,25 @@ describe(`SSR snapshot admission`, () => { } for (const history of ssrSnapshotExamples) { it(`replays ${hook} SSR ${history.preparation}/${history.sourceState}/${history.rows.length}`, async () => { + oracleRoute(`case`) await checkHistory(history, makeReach()) }) } for (const campaign of [`fixed`, `random`] as const) { it(`${hook} admits reproducible SSR snapshots (${campaign})`, async () => { const reach = makeReach() + const campaignParameters = { + ...(campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(120) }) + : oraclePropertyOptions(120, properties[hook])), + examples: ssrSnapshotExamples.map((history) => [history]), + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(ssrSnapshotHistoryArbitrary, (history) => checkHistory(history, reach), ), - { - ...(campaign === `fixed` - ? { numRuns: oracleRuns(120), seed: 2229 } - : oraclePropertyOptions(120, properties[hook])), - examples: ssrSnapshotExamples.map((history) => [history]), - }, + campaignParameters, ) reach.assertReached() console.info( @@ -1334,8 +1354,9 @@ const createStreamReach = () => describe(`stream render snapshot cut`, () => { for (const ordinaryFirst of [false, true]) for (const splitHydration of [false, true]) - it(`retains an ordinary loading view when its stream later fails, ordinaryFirst=${ordinaryFirst}, split=${splitHydration}`, () => - checkStreamCut( + it(`retains an ordinary loading view when its stream later fails, ordinaryFirst=${ordinaryFirst}, split=${splitHydration}`, () => { + oracleRoute(`case`) + return checkStreamCut( { count: 0, writes: 0, @@ -1346,12 +1367,14 @@ describe(`stream render snapshot cut`, () => { splitHydration, }, createStreamReach(), - )) + ) + }) for (const count of [0, 1]) for (const ordinaryFirst of [false, true]) for (const splitHydration of [false, true]) - it(`hydrates each held-source view with count=${count}, ordinaryFirst=${ordinaryFirst}, split=${splitHydration}`, () => - checkStreamCut( + it(`hydrates each held-source view with count=${count}, ordinaryFirst=${ordinaryFirst}, split=${splitHydration}`, () => { + oracleRoute(`case`) + return checkStreamCut( { count, writes: 0, @@ -1362,19 +1385,25 @@ describe(`stream render snapshot cut`, () => { splitHydration, }, createStreamReach(), - )) - it(`keeps an ordinary reader from blocking a shared Suspense preload`, () => - checkStreamCut( + ) + }) + it(`keeps an ordinary reader from blocking a shared Suspense preload`, () => { + oracleRoute(`fixed history`) + return checkStreamCut( { count: 1, writes: 0, ordinaryFirst: true, browserWrite: true }, createStreamReach(), - )) - it(`hydrates an ordinary reader rendered after its Suspense sibling`, () => - checkStreamCut( + ) + }) + it(`hydrates an ordinary reader rendered after its Suspense sibling`, () => { + oracleRoute(`fixed history`) + return checkStreamCut( { count: 1, writes: 0, ordinaryLast: true, browserWrite: true }, createStreamReach(), - )) - it(`retains the Suspense view after its ordinary sibling commits`, () => - checkStreamCut( + ) + }) + it(`retains the Suspense view after its ordinary sibling commits`, () => { + oracleRoute(`fixed history`) + return checkStreamCut( { count: 1, writes: 0, @@ -1383,18 +1412,26 @@ describe(`stream render snapshot cut`, () => { splitHydration: true, }, createStreamReach(), - )) + ) + }) for (const history of [ { count: 0, writes: 1 }, { count: 1, writes: 1 }, { count: 1, writes: 0 }, ]) - it(`replays count=${history.count} later-writes=${history.writes}`, () => - checkStreamCut(history, createStreamReach())) + it(`replays count=${history.count} later-writes=${history.writes}`, () => { + oracleRoute(`case`) + return checkStreamCut(history, createStreamReach()) + }) for (const campaign of [`fixed`, `random`] as const) it(`transports the rendered view (${campaign})`, async () => { const reach = createStreamReach() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(40) }) + : oraclePropertyOptions(40, `react.ssr-stream-cut`) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty( fc @@ -1428,9 +1465,7 @@ describe(`stream render snapshot cut`, () => { }), (history) => checkStreamCut(history, reach), ), - campaign === `fixed` - ? { numRuns: oracleRuns(40), seed: 2229 } - : oraclePropertyOptions(40, `react.ssr-stream-cut`), + parameters, ) } finally { // Keep reached premises visible when the first refinement check fails. diff --git a/packages/react-db/tests/descriptor-start-release-oracle.test.tsx b/packages/react-db/tests/descriptor-start-release-oracle.test.tsx index d958c33386..a311cfa657 100644 --- a/packages/react-db/tests/descriptor-start-release-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-start-release-oracle.test.tsx @@ -18,6 +18,7 @@ import { } from '../../db/tests/descriptor-start-release-oracle' import { createOracleReach } from '../../db/tests/oracle-reach' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { descriptorStartFixture } from '../../db/tests/descriptor-start-release-support' import { withOracleCleanup } from '../../db/tests/utils' import type { DescriptorStartHistory } from '../../db/tests/descriptor-start-release-oracle' @@ -84,8 +85,12 @@ it.each( descriptorStartHistories.flatMap((history) => hooks.map((hook) => ({ history, hook })), ), -)(`releases independent descriptor starts in React: %j`, ({ history, hook }) => - checkHistory(history, hook), +)( + `releases independent descriptor starts in React: %j`, + ({ history, hook }) => { + oracleRoute(`case`) + return checkHistory(history, hook) + }, ) it.each([ @@ -94,6 +99,7 @@ it.each([ `leaked-resource`, `wrong-row`, ] as const)(`rejects wrong multi-source release observation: %s`, (fault) => { + oracleRoute(`calibration`) const history = { acquired: 3, failedStarts: 3, eagerStarts: 7 } const actual = expectedDescriptorStart(history, `cleaned`) checkDescriptorStart(actual, history, `cleaned`) @@ -105,6 +111,11 @@ it.each([ }) declareOracle({ + owns: [ + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], check: [checkDescriptorStart], model: [expectedDescriptorStart], grammar: [descriptorStartHistories], diff --git a/packages/react-db/tests/effect-callback-oracle.test.tsx b/packages/react-db/tests/effect-callback-oracle.test.tsx index 4c11249722..3e5e486a92 100644 --- a/packages/react-db/tests/effect-callback-oracle.test.tsx +++ b/packages/react-db/tests/effect-callback-oracle.test.tsx @@ -15,6 +15,8 @@ import { runCallbackHistory, } from '../../db/tests/query/effect-callback-oracle' import { checkWithCleanup } from '../../db/tests/query/virtual-row-fields-support' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { flushPromises, mockSyncCollectionOptions } from '../../db/tests/utils' import type { CallbackOutput, @@ -35,6 +37,7 @@ afterAll(() => { test.each(callbackHistories)( `receives current Effect values across callback profiles: %j`, async (history) => { + oracleRoute(`case`) ran = true await runCallbackHistory( history, @@ -101,3 +104,8 @@ test.each(callbackHistories)( ) }, ) + +declareOracle({ + owns: [`src/useLiveQueryEffect.ts#useLiveQueryEffect`], + grammar: [callbackHistories], +}) diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index 2cde689f47..1c53ae12f8 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -325,7 +325,7 @@ const reactInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(reactInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx b/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx index 78fdce59a8..8d8bbfe7a8 100644 --- a/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx +++ b/packages/react-db/tests/infinite-query-render-cuts-oracle.test.tsx @@ -40,6 +40,7 @@ import { } from '@tanstack/db' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveQuery } from '../src/useLiveQuery' import { @@ -183,7 +184,13 @@ async function checkAbandonedRenderAcquisitions( ) } -declareOracle({ check: [checkAbandonedRenderAcquisitions] }) +declareOracle({ + owns: [ + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveQuery.ts#useLiveQuery`, + ], + check: [checkAbandonedRenderAcquisitions], +}) describe(`on-demand sources match useLiveQuery`, () => { it.each([ @@ -193,11 +200,13 @@ describe(`on-demand sources match useLiveQuery`, () => { ])( `starts no acquisition attempt for a render that never commits, as useLiveQuery does (%o)`, async ({ form, wrapped }) => { + oracleRoute(`case`) await checkAbandonedRenderAcquisitions(form, wrapped) }, ) it(`requests the first window and commits the first value as useLiveQuery does across a StrictMode double render`, async () => { + oracleRoute(`fixed history`) const reference = await firstWindowLoadsForStrictMount(`live`) // No provider work before commit: the on-demand window is unpublished. expect(reference.firstCommit).toEqual({ status: `loading`, ids: [] }) @@ -221,6 +230,7 @@ declareOracle({ model: [expectedReadyFirstCommit] }) describe(`agreement with useLiveQuery`, () => { it(`commits the same ready rows first as useLiveQuery for a synchronous source`, async () => { + oracleRoute(`fixed history`) const source = makeSyncSource(10) const firstCommit = ( useResult: () => { status: string; data: Array }, diff --git a/packages/react-db/tests/infinite-query-render-cuts-support.tsx b/packages/react-db/tests/infinite-query-render-cuts-support.tsx index 7388301247..735ee85d34 100644 --- a/packages/react-db/tests/infinite-query-render-cuts-support.tsx +++ b/packages/react-db/tests/infinite-query-render-cuts-support.tsx @@ -4,7 +4,7 @@ import { mockSyncCollectionOptions } from '../../db/tests/utils' import type { ReactNode } from 'react' // Shared fixtures for the infinite query render-cut tests. The laws are owned by -// infinite-query-render-cuts-oracle.test.tsx, and the ordinary pinned tests live in +// infinite-query-render-cuts-oracle.test.tsx, and the ordinary fixed tests live in // infinite-query-render-cuts.test.tsx. export type Row = { id: string; label: string; rank: number } diff --git a/packages/react-db/tests/source-id-reuse-oracle.test.tsx b/packages/react-db/tests/source-id-reuse-oracle.test.tsx index e46540e88c..6810f69184 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -64,6 +64,7 @@ import { useLiveQuery } from '../src/useLiveQuery' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { boundOptionHistories, @@ -95,6 +96,7 @@ it.each( )( `retains inline options until identity changes: %j`, async ({ hook, option, identity }) => { + oracleRoute(`case`) const rows = source( `inline-options-${hook}-${option}-${identity}`, `before`, @@ -222,6 +224,12 @@ function checkRerender( } declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], check: [checkRerender, checkReleaseHistory, checkBoundOptions], model: [expectedCollision], grammar: [boundOptionHistories], @@ -742,8 +750,10 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, for (const atMount of probe.requiresPriorClaim ? [false] : [false, true]) - it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source (${atMount ? `mount` : `rerender`})`, () => - checkReleaseHistory(makeCause, hook, read, atMount)) + it(`after ${causeName} in ${hookName}, ${readerName} starts the shared source (${atMount ? `mount` : `rerender`})`, () => { + oracleRoute(`case`) + return checkReleaseHistory(makeCause, hook, read, atMount) + }) } }) it.each([ @@ -752,6 +762,7 @@ it.each([ `wrong-row`, `duplicate-row`, ] as const)(`rejects wrong descriptor release observation: %s`, (fault) => { + oracleRoute(`calibration`) const expected = expectedReleaseObservation({ id: `one`, value: `authored`, @@ -774,6 +785,7 @@ declareOracle({ describe(`React source ID reuse`, () => { it(`rejects a different active source with the same ID before exposing stale rows`, async () => { + oracleRoute(`fixed history`) const first = source(`settings`, `first`) const second = source(`settings`, `second`) const hook = renderHook( @@ -791,6 +803,7 @@ describe(`React source ID reuse`, () => { }) it(`rejects a previously used ID after another source and a query change`, async () => { + oracleRoute(`fixed history`) const first = source(`settings`, `first`) const other = source(`other`, `other`) const second = source(`settings`, `second`) @@ -820,6 +833,7 @@ describe(`React source ID reuse`, () => { }) it(`accepts the same source object and a different source ID`, async () => { + oracleRoute(`fixed history`) const first = source(`settings`, `first`) const other = source(`other`, `other`) const hook = renderHook( @@ -844,6 +858,7 @@ describe(`React source ID reuse`, () => { }) it(`keeps a mounted hook's ID binding after source cleanup`, async () => { + oracleRoute(`fixed history`) const first = source(`settings`, `first`) const second = source(`settings`, `second`) const hook = renderHook( @@ -861,6 +876,7 @@ describe(`React source ID reuse`, () => { }) it(`remembers each client scope when one mounted hook switches clients`, async () => { + oracleRoute(`fixed history`) const firstClient = new DbClient() const secondClient = new DbClient() const first = source(`settings`, `first`) @@ -893,6 +909,7 @@ describe(`React source ID reuse`, () => { }) it(`rejects same-ID reuse after a suspended render starts a source query`, async () => { + oracleRoute(`fixed history`) const first = source(`first`, `first`) const abandoned = source(`candidate`, `abandoned`) const replacement = source(`candidate`, `replacement`) @@ -955,6 +972,7 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { ) it(`rejects a different active source with the same ID before exposing stale rows`, async () => { + oracleRoute(`fixed history`) const first = source(`settings`, `first`) const second = source(`settings`, `second`) const hook = renderHook(({ current }) => useInfinite(current), { @@ -969,6 +987,7 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { }) it(`rejects a previously used ID after another source`, async () => { + oracleRoute(`fixed history`) const first = source(`settings`, `first`) const other = source(`other`, `other`) const second = source(`settings`, `second`) @@ -990,6 +1009,7 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { }) it(`accepts the same source object and a different source ID`, async () => { + oracleRoute(`fixed history`) const first = source(`settings`, `first`) const other = source(`other`, `other`) const hook = renderHook(({ current }) => useInfinite(current), { @@ -1010,6 +1030,7 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { }) it(`rejects same-ID reuse after a suspended render starts a source query`, async () => { + oracleRoute(`fixed history`) const first = source(`first`, `first`) const abandoned = source(`candidate`, `abandoned`) const replacement = source(`candidate`, `replacement`) diff --git a/packages/react-db/tests/source-id-reuse-support.tsx b/packages/react-db/tests/source-id-reuse-support.tsx index 2ee1ead0f7..ac8164957b 100644 --- a/packages/react-db/tests/source-id-reuse-support.tsx +++ b/packages/react-db/tests/source-id-reuse-support.tsx @@ -4,7 +4,7 @@ import { mockSyncCollectionOptions } from '../../db/tests/utils' import type { ReactNode } from 'react' // Shared fixtures for the React source ID reuse tests. The reuse law is owned by -// source-id-reuse-oracle.test.tsx; ordinary pinned tests live in source-id-reuse.test.tsx. +// source-id-reuse-oracle.test.tsx; ordinary fixed tests live in source-id-reuse.test.tsx. export type Row = { id: string; value: string; rank: number } export class TestErrorBoundary extends Component< diff --git a/packages/react-db/tests/ssr-effects-oracle.test.ts b/packages/react-db/tests/ssr-effects-oracle.test.ts index 0530e4418f..93f5a017c6 100644 --- a/packages/react-db/tests/ssr-effects-oracle.test.ts +++ b/packages/react-db/tests/ssr-effects-oracle.test.ts @@ -32,8 +32,11 @@ import { import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { @@ -166,7 +169,15 @@ function checkRender( }) } -declareOracle({ check: [checkRender], grammar: [histories] }) +declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], + check: [checkRender], + grammar: [histories], +}) for (const runtime of runtimes) { for (const path of paths) { const project = (history: SsrSnapshotHistory): SsrSnapshotHistory => @@ -188,6 +199,7 @@ for (const runtime of runtimes) { register( `${runtime.name} ${path} renders without client layout diagnostics (${campaign})`, async () => { + oracleRoute(campaign === `fixed` ? `fixed seed` : `random seed`) const states = path.startsWith(`ordinary`) ? [ `rendered`, @@ -205,6 +217,14 @@ for (const runtime of runtimes) { const fidelity = createOracleFailureFidelity() let index = 0 try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2291, { + numRuns: oracleRuns(24) + inputs.length, + examples: inputs, + }) + : oraclePropertyOptions(24, property, { examples: inputs }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(grammar, (history) => @@ -242,13 +262,7 @@ for (const runtime of runtimes) { checkRender(history, actual, path) }), ), - campaign === `fixed` - ? { - numRuns: oracleRuns(24) + inputs.length, - seed: 2291, - examples: inputs, - } - : oraclePropertyOptions(24, property, { examples: inputs }), + parameters, ), ) reach.assertReached() @@ -269,6 +283,7 @@ for (const runtime of runtimes) { // without presenting repeated constant inputs as a generated history campaign. for (const runtime of runtimes) { oracleReplayTest(it)(`${runtime.name} disabled SSR diagnostic replay`, () => { + oracleRoute(`fixed history`) const history: SsrSnapshotHistory = { preparation: `none`, sourceState: `idle`, @@ -293,6 +308,7 @@ for (const runtime of runtimes) { oracleReplayTest(it)( `distinguishes a client-layout diagnostic from unrelated messages`, () => { + oracleRoute(`calibration`) const history: SsrSnapshotHistory = { preparation: `none`, sourceState: `idle`, diff --git a/packages/react-db/tests/ssr-runtime-error-handoff-oracle.test.tsx b/packages/react-db/tests/ssr-runtime-error-handoff-oracle.test.tsx index e947d881cd..d4842a8542 100644 --- a/packages/react-db/tests/ssr-runtime-error-handoff-oracle.test.tsx +++ b/packages/react-db/tests/ssr-runtime-error-handoff-oracle.test.tsx @@ -25,9 +25,12 @@ import { ssrRuntimeErrorHistoryArbitrary, } from '../../db/tests/ssr-snapshot-admission-oracle' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' @@ -288,28 +291,40 @@ async function checkRuntimeHistory( // checkRuntimeHistory compares the server view, the browser's first hydration // view and its committed view with the shared runtime model's expected views. -declareOracle({ check: [checkRuntimeHistory] }) +declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveQuery.ts#useLiveQuery`, + ], + check: [checkRuntimeHistory], +}) describe(`runtime errors across SSR hydration`, () => { for (const hook of [`ordinary`, `infinite`] as const) { for (const history of ssrRuntimeErrorExamples) it.skipIf(oracleReplayRequested())( `${hook} runtime ${history.serverOutcome}/${history.browserOutcome} preload=${history.browserPreload} cause=${history.definedError}`, - () => checkRuntimeHistory(hook, history, runtimeReach(hook)), + () => { + oracleRoute(`case`) + return checkRuntimeHistory(hook, history, runtimeReach(hook)) + }, ) const runCampaign = async (campaign: `fixed` | `random`) => { const reach = runtimeReach(hook) try { + const parameters = { + ...(campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(64) }) + : oraclePropertyOptions(64, runtimeProperties[hook])), + examples: ssrRuntimeErrorExamples.map((history) => [history]), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(ssrRuntimeErrorHistoryArbitrary, (history) => checkRuntimeHistory(hook, history, reach), ), - { - ...(campaign === `fixed` - ? { numRuns: oracleRuns(64), seed: 2229 } - : oraclePropertyOptions(64, runtimeProperties[hook])), - examples: ssrRuntimeErrorExamples.map((history) => [history]), - }, + parameters, ) reach.assertReached() } finally { diff --git a/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx b/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx index 42baa9c54b..7971adfa10 100644 --- a/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx +++ b/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx @@ -13,10 +13,10 @@ * Suspense commits its rows. Rechecking the first binding after the second * rejects a cache repair that overwrites a retained Collection's settings. * Both unscoped and DbProvider-scoped caches receive the same histories. A - * second lane uses distinct constructor references with the same class name; - * names cannot replace the declared constructor reference. A presence lane + * same-name grammar uses distinct constructor references with the same class name; + * names cannot replace the declared constructor reference. A presence grammar * compares omitted fields with explicit undefined in both orders. - * A focused pending lane holds source writes while two separate boundaries + * A focused pending grammar holds source writes while two separate boundaries * show fallbacks, then checks the same output law after delivery. It crosses * mode/constructor equality and both cache scopes, without multiplying the * entire ready matrix. This driver judges no hydration, abandoned render, @@ -50,6 +50,7 @@ import { } from '../../db/tests/conformance/indexing-config-oracle' import { expectUnorderedRows } from '../../db/tests/conformance/result-laws' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { withOracleCleanup } from '../../db/tests/utils' import type { InitialQueryBuilder } from '@tanstack/db' @@ -58,6 +59,10 @@ import type { IndexSettings } from '../../db/tests/conformance/indexing-config-o import type { Row } from '../../db/tests/conformance/contract' declareOracle({ + owns: [ + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + `src/DbProvider.tsx#DbProvider`, + ], grammar: [indexConfigurationPairs, indexOptionAbsencePairs], model: [expectedIndexing], check: [ @@ -84,17 +89,17 @@ const namedConfigurations = ([`off`, `eager`] as const).flatMap((autoIndex) => const pairs = [ ...indexOptionAbsencePairs().map((history) => ({ ...history, - lane: `presence`, + kind: `presence`, })), ...indexConfigurationPairs(configurations).map((history) => ({ ...history, - lane: `built-in`, + kind: `built-in`, })), ...indexConfigurationPairs(namedConfigurations) .filter( ({ first, second }) => first.defaultIndexType !== second.defaultIndexType, ) - .map((history) => ({ ...history, lane: `same-name` })), + .map((history) => ({ ...history, kind: `same-name` })), ] const histories = pairs.flatMap((history, cell) => (history.first.autoIndex === `off` && @@ -150,9 +155,10 @@ afterAll(() => { test.each(histories)( `each initial Suspense binding keeps its indexing settings: %j`, - async ({ first, second, identity, cell, scope, lane, phase }) => { + async ({ first, second, identity, cell, scope, kind, phase }) => { + oracleRoute(`case`) const reach = createOracleReach( - `React Suspense indexing/${scope}/${phase}/${lane}/${identity}/${cell}`, + `React Suspense indexing/${scope}/${phase}/${kind}/${identity}/${cell}`, [ `first-admission`, `second-admission`, @@ -169,7 +175,7 @@ test.each(histories)( const pendingReach = phase === `pending` ? pendingReaches[scope]! : undefined pendingReach?.beginHistory() - if (lane === `same-name`) { + if (kind === `same-name`) { expect(first.defaultIndexType).not.toBe(second.defaultIndexType) expect(first.defaultIndexType?.name).toBe(second.defaultIndexType?.name) } diff --git a/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx b/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx index 609b39a692..ba3fc9c4ff 100644 --- a/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx +++ b/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx @@ -50,8 +50,11 @@ import { } from '../../db/tests/oracle-failure-fidelity' import { declareOracle, + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import type { WindowRow } from '../../db/tests/conformance/window-history-oracle' @@ -818,6 +821,10 @@ async function checkPreload( } declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], model: [advance], grammar: [retryArbitrary, replacementArbitrary, preloadArbitrary], check: [compare, checkRetry, checkReplacement, checkPreload], @@ -841,6 +848,16 @@ describe(`Suspense lifecycle histories`, () => { let index = 0 const fidelity = createOracleFailureFidelity() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { + numRuns: oracleRuns(24) + retryExamples.length, + examples: retryExamples, + }) + : oraclePropertyOptions(24, property, { + examples: retryExamples, + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(retryArbitrary, async (history) => { @@ -849,15 +866,7 @@ describe(`Suspense lifecycle histories`, () => { checkRetry(history, scope, keyed, reach), ) }), - campaign === `fixed` - ? { - numRuns: oracleRuns(24) + retryExamples.length, - seed: 2345, - examples: retryExamples, - } - : oraclePropertyOptions(24, property, { - examples: retryExamples, - }), + parameters, ), ) reach.assertReached() @@ -884,6 +893,16 @@ describe(`Suspense lifecycle histories`, () => { let index = 0 const fidelity = createOracleFailureFidelity() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { + numRuns: oracleRuns(32) + replacementExamples.length, + examples: replacementExamples, + }) + : oraclePropertyOptions(32, property, { + examples: replacementExamples, + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(replacementArbitrary, async (history) => { @@ -894,15 +913,7 @@ describe(`Suspense lifecycle histories`, () => { checkReplacement(history, reach), ) }), - campaign === `fixed` - ? { - numRuns: oracleRuns(32) + replacementExamples.length, - seed: 2345, - examples: replacementExamples, - } - : oraclePropertyOptions(32, property, { - examples: replacementExamples, - }), + parameters, ), ) reach.assertReached() @@ -930,6 +941,16 @@ describe(`Suspense lifecycle histories`, () => { const fidelity = createOracleFailureFidelity() let index = 0 try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { + numRuns: oracleRuns(24) + preloadExamples.length, + examples: preloadExamples, + }) + : oraclePropertyOptions(24, property, { + examples: preloadExamples, + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(preloadArbitrary, async (history) => { @@ -938,15 +959,7 @@ describe(`Suspense lifecycle histories`, () => { checkPreload(history, scope, reach), ) }), - campaign === `fixed` - ? { - numRuns: oracleRuns(24) + preloadExamples.length, - seed: 2345, - examples: preloadExamples, - } - : oraclePropertyOptions(24, property, { - examples: preloadExamples, - }), + parameters, ), ) reach.assertReached() @@ -1641,6 +1654,11 @@ for (const scope of [`none`, `provider`, `explicit`] as const) ) const fidelity = createOracleFailureFidelity() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2348, { numRuns: oracleRuns(8) }) + : oraclePropertyOptions(8, property) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(siblingsArbitrary, async (history) => { @@ -1651,9 +1669,7 @@ for (const scope of [`none`, `provider`, `explicit`] as const) ) } }), - campaign === `fixed` - ? { numRuns: oracleRuns(8), seed: 2348 } - : oraclePropertyOptions(8, property), + parameters, ), ) reach.assertReached() diff --git a/packages/react-db/tests/suspense-local-options-oracle.test.tsx b/packages/react-db/tests/suspense-local-options-oracle.test.tsx index dfa865d074..bf2b15e2e5 100644 --- a/packages/react-db/tests/suspense-local-options-oracle.test.tsx +++ b/packages/react-db/tests/suspense-local-options-oracle.test.tsx @@ -7,9 +7,9 @@ * each committed mount and the first is checked again after its peer mounts. * These mounts create no reusable DbClient preload record. Cached-result * getKey/schema conflicts are judged by cache-options-oracle's warm driver. - * A focused pending lane keeps delivery held until two separate boundaries + * A focused pending grammar keeps delivery held until two separate boundaries * show their fallbacks. Equal/different callback and scalar declarations then - * use the same committed-output comparison as the ready lane. It checks no + * use the same committed-output comparison as the ready grammar. It checks no * abandoned render, rejection or cleanup race. * This receiver makes no server-render or hydration scheduling claim. */ @@ -31,6 +31,7 @@ import { localOptionPairs, } from '../../db/tests/live-query-cache-options-oracle' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { withOracleCleanup } from '../../db/tests/utils' import type { Collection, Context, InitialQueryBuilder } from '@tanstack/db' @@ -38,6 +39,10 @@ import type { UseLiveQueryConfig } from '../src/useLiveQuery' import type { ReactNode } from 'react' declareOracle({ + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], grammar: [localOptionPairs], check: [checkLocalOutputOwnership], }) @@ -91,6 +96,7 @@ afterAll(() => { test.each(histories)( `independent Suspense output options: %j`, async (history) => { + oracleRoute(`case`) const reach = createOracleReach( `Suspense local options/${history.scope}/${history.phase}/${history.cell}`, [ diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx index a196313acf..2c8c3351f2 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test-d.tsx @@ -32,7 +32,7 @@ describe(`useLiveInfiniteQuery type assertions`, () => { * Law and source: the public pre-created live-query Collection overload must * preserve its row, key, and utility types through `useLiveInfiniteQuery`. * The compile-time observation cut is the returned public result. Missing-row - * and wrong-key accesses are hostile controls. Pagination timing and runtime + * and wrong-key accesses are mutant controls. Pagination timing and runtime * page contents remain in the infinite-query conformance suite. */ it(`preserves pre-created collection row, key, and utility types`, () => { diff --git a/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx b/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx index b778671007..a550403e5e 100644 --- a/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx +++ b/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx @@ -39,6 +39,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection, debounceStrategy } from '@tanstack/db' import { usePacedMutations } from '../src/usePacedMutations' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { mockSyncCollectionOptions, resetCleanupQueue, @@ -298,9 +299,14 @@ describe.each([false, true])( `paced callback ownership with StrictMode=%s`, (strictMode) => { it.each(histories)(`models $name`, async (history) => { + oracleRoute(`case`) await driveHistory(history, strictMode) }) }, ) -declareOracle({ check: [driveHistory], model: [expectedOutcome] }) +declareOracle({ + owns: [`src/usePacedMutations.ts#usePacedMutations`], + check: [driveHistory], + model: [expectedOutcome], +}) diff --git a/packages/react-db/tests/window-render-oracle.test.tsx b/packages/react-db/tests/window-render-oracle.test.tsx index 60c1f8cb34..ffecde1f9c 100644 --- a/packages/react-db/tests/window-render-oracle.test.tsx +++ b/packages/react-db/tests/window-render-oracle.test.tsx @@ -36,8 +36,11 @@ import { } from '../../db/tests/oracle-failure-fidelity' import { declareOracle, + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import type { InitialQueryBuilder } from '@tanstack/db' @@ -306,19 +309,18 @@ describe(`window changes at React commit`, () => { }, ) try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2291, { + numRuns: oracleRuns(6) + examples.length, + examples: [...examples].map(([value]) => [value]), + }) + : oraclePropertyOptions(6, property, { + examples: [...examples].map(([value]) => [value]), + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => - fc.assert( - propertyUnderTest, - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + examples.length, - seed: 2291, - examples: [...examples].map(([value]) => [value]), - } - : oraclePropertyOptions(6, property, { - examples: [...examples].map(([value]) => [value]), - }), - ), + fc.assert(propertyUnderTest, parameters), ) } finally { console.info( @@ -332,6 +334,7 @@ describe(`window changes at React commit`, () => { } }) declareOracle({ + owns: [`src/useLiveQuery.ts#useLiveQuery`], grammar: [renderHistoryArbitrary], check: [checkRenderHistory], }) @@ -619,6 +622,16 @@ for (const client of [false, true]) let index = 0 const fidelity = createOracleFailureFidelity() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { + numRuns: oracleRuns(6) + examples.length, + examples: [...examples].map(([value]) => [value]), + }) + : oraclePropertyOptions(6, property, { + examples: [...examples].map(([value]) => [value]), + }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(renderHistoryArbitrary, async (history) => { @@ -634,15 +647,7 @@ for (const client of [false, true]) ), ) }), - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + examples.length, - seed: 2345, - examples: [...examples].map(([value]) => [value]), - } - : oraclePropertyOptions(6, property, { - examples: [...examples].map(([value]) => [value]), - }), + parameters, ), ) } finally { @@ -655,6 +660,7 @@ for (const client of [false, true]) }) } declareOracle({ + owns: [`src/useLiveQuery.ts#useLiveQuery`], grammar: [renderHistoryArbitrary], check: [checkRetryOwnershipHistory], }) 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 5befd91fe6..84c8ece5c8 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 @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { afterEach, expect, it } from 'vitest' import { + oracleFixedSeed, oracleRandomParameters, oracleReplayTest, + oracleRoute, + oracleRouteOf, readOracleRunConfig, } from '../../db/tests/oracle-config.js' import { declareOracle } from '../../db/tests/oracle-declaration.js' @@ -52,7 +55,7 @@ afterEach(runActiveCleanupFns) * Refinement evidence: query execution counts prove the SELECT reached the * 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. + * properties and budgets in retained fixed-seed and seedless-random campaigns. * 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 @@ -83,9 +86,9 @@ const aliasOracleRuns = readAliasOracleRuns() const aliasOracleReplay = readOracleRunConfig() /** - * Registers a property's two campaigns. The fixed campaign pins its seed and + * Registers a property's two campaigns. The fixed campaign fixes its campaign 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 + * reseeded campaigns, and as the replay of its property, where the shared helper * returns the recorded path. */ function registerAliasOracle( @@ -98,12 +101,16 @@ function registerAliasOracle( aliasOracleReplay, property, ) - oracleReplayTest(it)(`${title} [fixed]`, () => - check({ numRuns: aliasOracleRuns, seed: FIXED_ALIAS_ORACLE_SEED }), - ) - oracleReplayTest(it, property)(`${title} [random]`, () => - check(randomParameters), - ) + oracleReplayTest(it)(`${title} [fixed]`, () => { + oracleRoute(`fixed seed`) + return check( + oracleFixedSeed(FIXED_ALIAS_ORACLE_SEED, { numRuns: aliasOracleRuns }), + ) + }) + oracleReplayTest(it, property)(`${title} [random]`, () => { + oracleRoute(`random seed`) + return check(randomParameters) + }) } const writeResultAliasesArbitrary = fc.uniqueArray( @@ -147,16 +154,17 @@ function expectAliasGrammar( ] const observed = new Set() + const parameters = oracleFixedSeed(FIXED_ALIAS_ORACLE_SEED, { + numRuns: 50, + examples: witnesses.map((aliases) => [aliases]), + }) + oracleRoute(oracleRouteOf(parameters)) fc.assert( fc.property(arbitrary, (aliases) => { expect(isLegalAliasSequence(aliases, allowedAliases)).toBe(true) observed.add(JSON.stringify(aliases)) }), - { - seed: FIXED_ALIAS_ORACLE_SEED, - numRuns: 50, - examples: witnesses.map((aliases) => [aliases]), - }, + parameters, ) witnesses.forEach((aliases) => { @@ -175,6 +183,7 @@ function expectAliasGrammar( } it(`controls alias grammar reconstruction, ablation, range, and exclusion`, () => { + oracleRoute(`calibration`) expectAliasGrammar(writeResultAliasesArbitrary, writeResultFieldNames) expectAliasGrammar(statementResultAliasesArbitrary, statementResultAliasNames) }) @@ -183,6 +192,13 @@ registerAliasOracle( `op-sqlite.write-marker-aliases`, `preserves generated single-row write-marker aliases in direct row arrays`, async (parameters) => { + const campaignParameters = { + ...parameters, + ...(parameters.path === undefined + ? { examples: [[[`rowsAffected`]], [[...writeResultFieldNames]]] } + : { endOnFailure: true }), + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(writeResultAliasesArbitrary, async (aliases) => { const row = Object.fromEntries( @@ -191,12 +207,7 @@ registerAliasOracle( const actual = await queryInjectedResult>([row]) expectExactAliasRows(actual, [row]) }), - { - ...parameters, - ...(parameters.path === undefined - ? { examples: [[[`rowsAffected`]], [[...writeResultFieldNames]]] } - : { endOnFailure: true }), - }, + campaignParameters, ) }, ) @@ -205,6 +216,18 @@ registerAliasOracle( `op-sqlite.result-carrier-equivalence`, `returns the same rows across equivalent documented result carriers`, async (parameters) => { + const runParameters = { + ...parameters, + ...(parameters.path === undefined + ? { + examples: [ + [[`rowsAffected`]], + [[`rows`, `rawRows`, `columnNames`, `ordinary_name`]], + ], + } + : { endOnFailure: true }), + } + oracleRoute(oracleRouteOf(runParameters)) await fc.assert( fc.asyncProperty(statementResultAliasesArbitrary, async (aliases) => { const { expected } = aliasQueryCase(aliases) @@ -249,17 +272,7 @@ registerAliasOracle( expectExactAliasRows(actual, expected) } }), - { - ...parameters, - ...(parameters.path === undefined - ? { - examples: [ - [[`rowsAffected`]], - [[`rows`, `rawRows`, `columnNames`, `ordinary_name`]], - ], - } - : { endOnFailure: true }), - }, + runParameters, ) }, ) @@ -299,6 +312,7 @@ function aliasQueryCase(aliases: ReadonlyArray): { } it(`preserves every statement-result field name when used as a SQL alias`, async () => { + oracleRoute(`case`) await withColumnarDriver(async ({ driver, queryExecutions }) => { const aliases = statementResultFieldNames const { sql, params, expected } = aliasQueryCase(aliases) @@ -310,6 +324,7 @@ it(`preserves every statement-result field name when used as a SQL alias`, async }) it(`preserves statement-result field names in direct row arrays`, async () => { + oracleRoute(`case`) const dbPath = createTempSqlitePath() const database = createOpSQLiteTestDatabase({ filename: dbPath, @@ -333,6 +348,15 @@ registerAliasOracle( `preserves generated legal SQL aliases through v14 columnar rows`, async (parameters) => { await withColumnarDriver(async ({ driver, queryExecutions }) => { + const campaignParameters: fc.Parameters< + [Array<(typeof statementResultAliasNames)[number]>] + > = { + ...parameters, + ...(parameters.path === undefined + ? { examples: [[[...statementResultFieldNames]]] } + : { endOnFailure: true }), + } + oracleRoute(oracleRouteOf(campaignParameters)) await fc.assert( fc.asyncProperty(statementResultAliasesArbitrary, async (aliases) => { const executionsBefore = queryExecutions() @@ -347,12 +371,7 @@ registerAliasOracle( expect(queryExecutions()).toBe(executionsBefore + 1) expectExactAliasRows(actual, expected) }), - { - ...parameters, - ...(parameters.path === undefined - ? { examples: [[[...statementResultFieldNames]]] } - : { endOnFailure: true }), - }, + campaignParameters, ) }) }, @@ -361,6 +380,7 @@ registerAliasOracle( // The reference rows for a SQL alias case come from the alias list alone: no // production decoder runs here. Each row maps every alias to its own scalar. declareOracle({ + owns: [`src/op-sqlite-driver.ts`], model: [aliasQueryCase], grammar: [writeResultAliasesArbitrary, statementResultAliasesArbitrary], }) @@ -395,6 +415,7 @@ const reservedAliasWrongAnswers = (() => { it.each(reservedAliasWrongAnswers)( `reserved-alias checker rejects wrong answer: $name`, ({ actual }) => { + oracleRoute(`calibration`) const { expected } = aliasQueryCase([ `rows`, `rowsAffected`, @@ -407,11 +428,13 @@ it.each(reservedAliasWrongAnswers)( ) it(`reserved-alias checker accepts the independent reference rows`, () => { + oracleRoute(`calibration`) const { expected } = aliasQueryCase([`rows`, `rowsAffected`, `ordinary_name`]) expectExactAliasRows(expected, expected) }) it(`reserved-alias checker shrinks and replays the same row-law violation`, () => { + oracleRoute(`calibration`) const challenge = (aliases: ReadonlyArray) => { const { expected } = aliasQueryCase(aliases) expectExactAliasRows([], expected) @@ -428,12 +451,9 @@ it(`reserved-alias checker shrinks and replays the same row-law violation`, () = }), challenge, ) - const original = fc.check(property, { - seed: 165903, - numRuns: 20, - }) + const original = fc.check(property, { seed: 165903, numRuns: 20 }) expect(original.failed).toBe(true) - expect(original.error).toContain( + expect(String(original.errorInstance)).toContain( `op-sqlite reserved-alias exact-row law violated`, ) if (original.counterexamplePath === null) { @@ -446,7 +466,7 @@ it(`reserved-alias checker shrinks and replays the same row-law violation`, () = endOnFailure: true, }) expect(reduced.failed).toBe(true) - expect(reduced.error).toContain( + expect(String(reduced.errorInstance)).toContain( `op-sqlite reserved-alias exact-row law violated`, ) expect(reduced.counterexample).toEqual(original.counterexample) diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts index 0eda3194a7..a1a00aa81f 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-driver.test.ts @@ -1,7 +1,7 @@ /** * Ordinary op-sqlite driver tests: provider receipts, result-envelope decoding, * transactions, and fixed SQL-alias cases. Each checks a fixed case against - * values written out in the test, a replay-configuration parser, or a hostile + * values written out in the test, a replay-configuration parser, or a mutant * fixture. None compares with an independent model, so none is oracle coverage. * The alias law and its generated checks are in op-sqlite-driver-oracle.test.ts, * and the shared helpers are in op-sqlite-driver-support.ts. @@ -328,7 +328,7 @@ it(`selects a fresh seed without changing the random alias campaign budget`, () ).toEqual({ numRuns: runs, seed: expect.any(Number) }) }) -it(`reseeds the random alias campaigns from a seed-only replay`, () => { +it(`reseeds the random alias campaigns from a campaign seed alone`, () => { expect( oracleRandomParameters( 50, diff --git a/packages/rxdb-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts b/packages/rxdb-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts index 8001dfc4f8..be039e8709 100644 --- a/packages/rxdb-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts +++ b/packages/rxdb-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { describe, it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -47,6 +50,7 @@ async function campaign(options: fc.Parameters<[unknown]>) { const reach = createOracleReach(property, reachStatesFor(driver), { minimum: 5, }) + oracleRoute(oracleRouteOf(options)) await fc.assert(eagerLiveSourceProperty(driver, reach), options) return reach } @@ -55,7 +59,9 @@ describe(`eager live-source conformance: RxDB`, () => { oracleReplayTest(it)( `conforms on a fixed-seed campaign`, async () => { - const reach = await campaign({ seed: 2118, numRuns: oracleRuns(runs) }) + const reach = await campaign( + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), + ) reach.assertReached() }, campaignTimeoutMs, @@ -72,6 +78,7 @@ describe(`eager live-source conformance: RxDB`, () => { }) declareOracle({ + owns: [`src/rxdb.ts#rxdbCollectionOptions`], check: [checkEagerLiveSourceHistory, eagerLiveSourceProperty], model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], grammar: [historyArb], diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index bba314c10f..c1df9c893b 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -298,7 +298,7 @@ const solidDriver: LiveQueryDriver = { // rather than exposing a readable isLoadingError flag. That's a framework idiom, not a // gap — the error-status scenario is parametrized to assert it via the boundary. errorSurface: `throw`, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: true, pooledEqFilters: true }, } diff --git a/packages/solid-db/tests/row-materialization-oracle.test.tsx b/packages/solid-db/tests/row-materialization-oracle.test.tsx index f67a9a8333..a6e4c863ed 100644 --- a/packages/solid-db/tests/row-materialization-oracle.test.tsx +++ b/packages/solid-db/tests/row-materialization-oracle.test.tsx @@ -29,6 +29,7 @@ import { useLiveQuery } from '../src/useLiveQuery' import { createRowStore } from '../src/store-materialization' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import { ScenarioLifetime } from '../../db/tests/conformance/scenario-lifetime' import type { InitialQueryBuilder } from '@tanstack/db' @@ -136,6 +137,7 @@ function compareArray( } async function runHistory(history: History) { + oracleRoute(`case`) const parentState = history.initialParent === history.nextParent ? `same-parent-control` @@ -497,6 +499,7 @@ function compareVisibility( it.each([...visibilityHistories()])( `notifies helper visibility for $key $kind, initially=$initial`, async ({ kind, initial, key }) => { + oracleRoute(`case`) const reach = createOracleReach( `solid.materialization.visibility.${key}.${kind}.${initial}`, [`unchanged`, `hidden`, `visible`, `visibility-restored`], @@ -601,6 +604,7 @@ function compareLabel( // The getter is nested: spreading a root getter in a query projection would // evaluate it in Core before it could reach the framework adapter boundary. async function runGetterHistory(history: GetterHistory) { + oracleRoute(`case`) const reach = createOracleReach( `solid.getter.${history.input}.${history.object}.${history.later}.${history.stable}`, [ @@ -821,6 +825,7 @@ function compareGraph( it.each([...graphHistories()].filter((history) => history.crossed))( `rejects mixed-path corruption at crossed depth=$depth`, ({ depth, shared, crossed }) => { + oracleRoute(`calibration`) const expected = semanticGraph(depth, 0, shared, crossed) function corrupted(graph: Graph, remaining: number): Graph { if (`value` in graph) return { value: 99 } @@ -842,6 +847,7 @@ async function runGraphHistory({ shared: boolean crossed: boolean }) { + oracleRoute(`case`) const reach = createOracleReach(`solid.graph.${depth}.${shared}.${crossed}`, [ `graph-admission`, `graph-replacement`, @@ -1079,6 +1085,7 @@ function compareCallable( } async function runCallableHistory(history: CallableHistory) { + oracleRoute(`case`) const reach = createOracleReach( `solid.callable.${history.input}.${history.location}.${history.property}.${history.shape}`, [ @@ -1374,6 +1381,7 @@ function compareReservedField(observed: unknown, expected: unknown) { ) } async function runReservedFieldHistory(history: ReservedFieldHistory) { + oracleRoute(`case`) const reach = createOracleReach( `solid.own-field.${history.input}.${history.key}.${history.initial}.${history.payload}`, [ @@ -1603,6 +1611,7 @@ function compareMethods(actual: MethodObservation, version: number) { ).toEqual(expectedMethods(version)) } async function runMethodHistory(history: MethodHistory) { + oracleRoute(`case`) const reach = createOracleReach( `solid.methods.${history.input}.${history.subclass}.${history.shape}`, [ @@ -1732,6 +1741,10 @@ it.each([...methodHistories()])( runMethodHistory, ) declareOracle({ + owns: [ + `src/useLiveQuery.ts#useLiveQuery`, + `src/store-materialization.ts#createRowStore`, + ], model: [expectedMethods], grammar: [methodHistories], check: [compareMethods], diff --git a/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts b/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts index 6af5eff543..145768e8e4 100644 --- a/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts @@ -20,6 +20,7 @@ import { } from '../../db/tests/live-query-cache-options-oracle' import { withOracleCleanup } from '../../db/tests/utils' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' import type { CacheRow } from '../../db/tests/live-query-cache-options-oracle' @@ -38,11 +39,13 @@ afterAll(() => { ) }) declareOracle({ + owns: [`src/useLiveQuery.svelte.ts#useLiveQuery`], check: [checkCacheOptionAdmission, checkCacheOptionRows], model: [conflictingCacheOptions], grammar: [cacheOptionHistories], }) test.each(cacheOptionHistories)(`Svelte cached options %j`, async (history) => { + oracleRoute(`case`) reach.beginHistory() const client = new DbClient() const rows: Array = [ diff --git a/packages/svelte-db/tests/client-window-oracle.svelte.test.ts b/packages/svelte-db/tests/client-window-oracle.svelte.test.ts index efe69197d7..61e670f59e 100644 --- a/packages/svelte-db/tests/client-window-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/client-window-oracle.svelte.test.ts @@ -29,8 +29,11 @@ import { createOracleReach } from '../../db/tests/oracle-reach' import { createOracleFailureFidelity } from '../../db/tests/oracle-failure-fidelity' import { declareOracle, + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import type { @@ -256,6 +259,14 @@ describe(`Svelte client page identity`, () => { let index = 0 const fidelity = createOracleFailureFidelity() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2345, { + numRuns: oracleRuns(6) + examples.length, + examples, + }) + : oraclePropertyOptions(6, property, { examples }) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(clientWindowArbitrary, async (history) => { @@ -264,13 +275,7 @@ describe(`Svelte client page identity`, () => { checkClientWindowHistory(history, keyed, reach), ) }), - campaign === `fixed` - ? { - numRuns: oracleRuns(6) + examples.length, - seed: 2345, - examples, - } - : oraclePropertyOptions(6, property, { examples }), + parameters, ), ) reach.assertReached() @@ -283,7 +288,10 @@ describe(`Svelte client page identity`, () => { }, 30_000) } }) -declareOracle({ check: [checkClientWindowHistory] }) +declareOracle({ + owns: [`src/useLiveQuery.svelte.ts#useLiveQuery`], + check: [checkClientWindowHistory], +}) /** An unchanged explicit client key retains the authored input, including after * a rejected key read. Recovery must not replay a prior declarative move over @@ -454,6 +462,11 @@ for (const campaign of [`fixed`, `fresh`] as const) { const reach = createOracleReach(`${property}/${campaign}`, recoveryStates) const fidelity = createOracleFailureFidelity() try { + const parameters = + campaign === `fixed` + ? oracleFixedSeed(2348, { numRuns: oracleRuns(24) }) + : oraclePropertyOptions(24, property) + oracleRoute(oracleRouteOf(parameters)) await fidelity.assert(() => fc.assert( fc.asyncProperty(recoveryArbitrary, async (history) => { @@ -462,9 +475,7 @@ for (const campaign of [`fixed`, `fresh`] as const) { await fidelity.run(member, () => checkRecovery(member, reach)) } }), - campaign === `fixed` - ? { numRuns: oracleRuns(24), seed: 2348 } - : oraclePropertyOptions(24, property), + parameters, ), ) reach.assertReached() diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index b2d3e81fec..c4b2969115 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -288,7 +288,7 @@ const svelteDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts b/packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts index 39b0dd8089..1bd0226728 100644 --- a/packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/descriptor-start-release-oracle.svelte.test.ts @@ -17,6 +17,7 @@ import { import { descriptorStartFixture } from '../../db/tests/descriptor-start-release-support' import { createOracleReach } from '../../db/tests/oracle-reach' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { withOracleCleanup } from '../../db/tests/utils' import type { DescriptorStartHistory } from '../../db/tests/descriptor-start-release-oracle' @@ -70,10 +71,14 @@ async function checkHistory(history: DescriptorStartHistory) { it.each(descriptorStartHistories)( `releases independent descriptor starts in Svelte: %j`, - checkHistory, + (history) => { + oracleRoute(`case`) + return checkHistory(history) + }, ) declareOracle({ + owns: [`src/useLiveQuery.svelte.ts#useLiveQuery`], check: [checkDescriptorStart], model: [expectedDescriptorStart], grammar: [descriptorStartHistories], diff --git a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts index 74123a75d1..39d95dde83 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -270,7 +270,7 @@ const svelteInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(svelteInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts index c8ff8436db..57e07bc9b6 100644 --- a/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/ssr-runtime-error-oracle.svelte.test.ts @@ -10,7 +10,7 @@ * portable error-object identity. The fixture supplies native SSR, JSON * transport and hydrate(); construction and pre-effects record the two public * cuts. A failure is the provider's fail() before readiness, a loading error - * (`isLoadingError`). This lane does not model throwing provider callbacks, + * (`isLoadingError`). This grammar does not model throwing provider callbacks, * which crash the Collection (`isCrashed`), or Suspense. */ import { TextEncoder } from 'node:util' @@ -24,9 +24,12 @@ import { ssrRuntimeErrorHistoryArbitrary, } from '../../db/tests/ssr-snapshot-admission-oracle' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' @@ -218,27 +221,35 @@ async function checkRuntimeHistory( // checkRuntimeHistory compares the server view, the browser's first hydration // view and its committed view with the shared runtime model's expected views. -declareOracle({ check: [checkRuntimeHistory] }) +declareOracle({ + owns: [`src/useLiveQuery.svelte.ts#useLiveQuery`], + check: [checkRuntimeHistory], +}) describe(`Svelte SSR runtime errors`, () => { for (const history of ssrRuntimeErrorExamples) it.skipIf(oracleReplayRequested())( `runtime ${history.serverOutcome}/${history.browserOutcome} preload=${history.browserPreload} cause=${history.definedError}`, - () => checkRuntimeHistory(history, makeRuntimeReach()), + () => { + oracleRoute(`case`) + return checkRuntimeHistory(history, makeRuntimeReach()) + }, ) const runCampaign = async (campaign: `fixed` | `random`) => { const reach = makeRuntimeReach() try { + const parameters = { + ...(campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(64) }) + : oraclePropertyOptions(64, `svelte.ssr-runtime-errors`)), + examples: ssrRuntimeErrorExamples.map((history) => [history]), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(ssrRuntimeErrorHistoryArbitrary, (history) => checkRuntimeHistory(history, reach), ), - { - ...(campaign === `fixed` - ? { numRuns: oracleRuns(64), seed: 2229 } - : oraclePropertyOptions(64, `svelte.ssr-runtime-errors`)), - examples: ssrRuntimeErrorExamples.map((history) => [history]), - }, + parameters, ) reach.assertReached() } finally { diff --git a/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts index 5894fb64c9..98412994da 100644 --- a/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/ssr-snapshot-admission-oracle.svelte.test.ts @@ -3,7 +3,14 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { render } from 'svelte/server' import { BTreeIndex, DbClient, Query, collectionOptions } from '@tanstack/db' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' +import { declareOracle } from '../../db/tests/oracle-declaration' import { createOracleReach } from '../../db/tests/oracle-reach' import { expectedSsrSnapshot, @@ -131,22 +138,25 @@ describe(`Svelte SSR snapshot admission`, () => { } for (const history of ssrSnapshotExamples) { it(`replays SSR ${history.preparation}/${history.sourceState}/${history.rows.length}`, async () => { + oracleRoute(`case`) await checkHistory(history, makeReach()) }) } for (const campaign of [`fixed`, `random`] as const) { it(`admits reproducible SSR snapshots (${campaign})`, async () => { const reach = makeReach() + const parameters = { + ...(campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(120) }) + : oraclePropertyOptions(120, `svelte.ssr-snapshot`)), + examples: ssrSnapshotExamples.map((history) => [history]), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(ssrSnapshotHistoryArbitrary, (history) => checkHistory(history, reach), ), - { - ...(campaign === `fixed` - ? { numRuns: oracleRuns(120), seed: 2229 } - : oraclePropertyOptions(120, `svelte.ssr-snapshot`)), - examples: ssrSnapshotExamples.map((history) => [history]), - }, + parameters, ) reach.assertReached() console.info( @@ -156,3 +166,9 @@ describe(`Svelte SSR snapshot admission`, () => { }) } }) + +declareOracle({ + owns: [`src/useLiveQuery.svelte.ts#useLiveQuery`], + model: [expectedSsrSnapshot], + grammar: [ssrSnapshotHistoryArbitrary], +}) diff --git a/packages/svelte-db/tests/ssr-snapshot-fixture.ts b/packages/svelte-db/tests/ssr-snapshot-fixture.ts index f05cd2895a..762f011a0d 100644 --- a/packages/svelte-db/tests/ssr-snapshot-fixture.ts +++ b/packages/svelte-db/tests/ssr-snapshot-fixture.ts @@ -59,7 +59,7 @@ export async function renderSsrSnapshotHistory( if (history.sourceState === `ready`) await warming else void warming.catch(() => {}) } - // Register the warmed source for normalized dehydration. This lane models + // Register the warmed source for normalized dehydration. This `source` preparation models // explicit source registration, not another awaited preload operation. if (history.preparation === `source`) client.collection(descriptor) if ( diff --git a/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts b/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts index 851eab67ff..a34b8d55f6 100644 --- a/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/ssr-snapshot-handoff-oracle.svelte.test.ts @@ -12,11 +12,15 @@ import { ssrSnapshotHistoryArbitrary, } from '../../db/tests/ssr-snapshot-admission-oracle' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayRequested, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { createOracleReach } from '../../db/tests/oracle-reach' +import { declareOracle } from '../../db/tests/oracle-declaration' import { peopleQuery } from './ssr-test-utils' import SsrDbApp from './SsrDbApp.svelte' import type { createServer } from 'vite' @@ -199,21 +203,25 @@ async function check( describe(`Svelte real SSR handoff`, () => { for (const history of ssrSnapshotExamples) - it(`replays ${history.preparation}/${history.sourceState}/${history.rows.length} server-write=${!!history.serverWrite} browser-write=${!!history.browserWrite}`, () => - check(history, makeReach())) + it(`replays ${history.preparation}/${history.sourceState}/${history.rows.length} server-write=${!!history.serverWrite} browser-write=${!!history.browserWrite}`, () => { + oracleRoute(`case`) + return check(history, makeReach()) + }) for (const campaign of [`fixed`, `random`] as const) it(`matches the actual browser initialization (${campaign})`, async () => { const reach = makeReach() + const parameters = { + ...(campaign === `fixed` + ? oracleFixedSeed(2229, { numRuns: oracleRuns(120) }) + : oraclePropertyOptions(120, `svelte.ssr-handoff`)), + examples: ssrSnapshotExamples.map((history) => [history]), + } + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(ssrSnapshotHistoryArbitrary, (history) => check(history, reach), ), - { - ...(campaign === `fixed` - ? { numRuns: oracleRuns(120), seed: 2229 } - : oraclePropertyOptions(120, `svelte.ssr-handoff`)), - examples: ssrSnapshotExamples.map((history) => [history]), - }, + parameters, ) reach.assertReached() console.info( @@ -224,7 +232,7 @@ describe(`Svelte real SSR handoff`, () => { }) /** - * This receiving lane keeps one client's transport while native Svelte roots + * This receiving path keeps one client's transport while native Svelte roots * initialize at different times. It reuses the shared preparation judgment and * receiving-history grammar. Only initialization kind changes the initial-view * obligation: hydrate() must reproduce its server markup; mount() must read @@ -364,7 +372,7 @@ async function checkReceivingHistory( try { // The real server fixture returns a JSON copy. A second copy here makes - // the transport boundary explicit for this receiving lane too. + // the transport boundary explicit for this receiving path too. client.hydrate(JSON.parse(JSON.stringify(rendered.state))) for (let index = 0; index < history.previousReaders; index++) { await initialize(`hydrate`, { @@ -463,19 +471,24 @@ describe(`Svelte SSR receiving readers`, () => { for (const history of ssrReceivingExamples) it.skipIf(oracleReplayRequested())( `replays ${history.initializationKind} ${history.mountLocation ?? `root`} after ${history.previousReaders} readers with ${history.snapshot.sourceState}/${history.snapshot.rows.length} server rows nesting=${history.providerNesting ?? `none`}`, - () => checkReceivingHistory(history, makeReceivingReach()), + () => { + oracleRoute(`case`) + return checkReceivingHistory(history, makeReceivingReach()) + }, ) const campaign = async (fixed: boolean) => { const reach = makeReceivingReach() try { + const parameters = fixed + ? oracleFixedSeed(2229, { numRuns: oracleRuns(120) }) + : oraclePropertyOptions(120, `svelte.ssr-receiving-reader`) + oracleRoute(oracleRouteOf(parameters)) await fc.assert( fc.asyncProperty(ssrReceivingHistoryArbitrary, (history) => checkReceivingHistory(history, reach), ), - fixed - ? { numRuns: oracleRuns(120), seed: 2229 } - : oraclePropertyOptions(120, `svelte.ssr-receiving-reader`), + parameters, ) reach.assertReached() } finally { @@ -496,3 +509,10 @@ describe(`Svelte SSR receiving readers`, () => { 30_000, ) }) + +declareOracle({ + owns: [`src/useLiveQuery.svelte.ts#useLiveQuery`], + model: [expectedSsrSnapshot, expectedSsrReceivingViews], + check: [check, checkReceivingHistory], + grammar: [ssrSnapshotHistoryArbitrary, ssrReceivingHistoryArbitrary], +}) diff --git a/packages/trailbase-db-collection/tests/ORACLE.md b/packages/trailbase-db-collection/tests/ORACLE.md index 8f54a58222..19ef24acf9 100644 --- a/packages/trailbase-db-collection/tests/ORACLE.md +++ b/packages/trailbase-db-collection/tests/ORACLE.md @@ -11,107 +11,105 @@ The same interpreter runs a fixed corpus and generated histories. ## Laws and scope - Published inserts, updates and deletes agree with the model after each event. -- Eager startup waits for its list; on-demand startup does not claim to load rows. +- Eager startup waits for its list; on-demand startup does not claim to load + rows. - A list starts only after the change subscription is established, in both - modes, so a change committed during or after a fetch reaches the listener. - An on-demand subset load that arrives before the listener runs waits until - `start` reports that the listener runs or cannot start. - Since #2115 a live query is ready when its subset load settles, not at the - source's `markReady()`, so the load itself must carry this ordering. The - wait still honors its caller: an abort rejects a waiting load with - `AbortError`, cleanup ends it quietly, and a listener that cannot start - rejects it with that failure instead of fetching. Because a load waits for - the listener and not for the subscription, it does not depend on the order - in which `start` and the load resume; a mutant that adds an await before - `start` subscribes is equivalent. -- The early-demand lane checks that law with its own service model: `list` - answers from the service rows at the moment it is called, and a change - reaches only subscriptions that exist when it commits. Its 16 histories - cross the subscription outcome (listen, subscribe rejects, reader fails, - abort, cleanup), a service change committed before the subscription, and a - real live query or the core subset boundary. Checkpoints after the demand, - after the service change, after the outcome, and after a later change - through the listener compare list calls, load or query state, status, - first-ready rows, and current rows. - Required startup failures reject. Later stream failures retain rows and report - the error, matching the adapter's current policy. -- Once a page and every change event for its rows have arrived, the - Collection's rows equal the service's rows. A page was read before any - change that the listener applies while the page is in flight, so the page - skips rows that such an event touched. Every page row is written as a - full-row `update`, which creates a missing row and replaces a held one, so - a page never collides with an event that core accepted but still holds. The page-event lane - enumerates 15 histories: eager startup or an on-demand load; an update, - delete, or insert; committed before the list call (on-demand only), while - the page is in flight, or after it applies. It compares the Collection's - rows with the service after the page settles and after a later change. A - generated lane (`trailbase.page-event`) extends the same law: 258 service - rows read in two pages of 256, cursor or offset paging, one or two - overlapping on-demand loads, and up to four inserts, updates, and deletes - among page releases. After every step a changed key holds the service's - value; after the history drains, every returned or changed key does. Four - named replays pin an update between pages, an update then a delete during a - page, overlapping loads around an update, and offset paging with two - updates. After the history drains, the Collection holds exactly the rows - that a page returned or an event touched and that the service still has. - The page fence orders events by a sequence that each sync run counts, so a - fresh run never skips a row because of an earlier run's event. The fence - keeps per-key sequences only while a load is in flight and clears them when - the last load ends; no public observation measures that bound. + modes, so a change committed during or after a fetch reaches the listener. An + on-demand subset load that arrives before the listener runs waits until + `start` reports that the listener runs or cannot start. Since #2115 a live + query is ready when its subset load settles, not at the source's + `markReady()`, so the load itself must carry this ordering. The wait still + honors its caller: an abort rejects a waiting load with `AbortError`, cleanup + ends it quietly, and a listener that cannot start rejects it with that failure + instead of fetching. Because a load waits for the listener and not for the + subscription, it does not depend on the order in which `start` and the load + resume; a mutant that adds an await before `start` subscribes is equivalent. +- The early-demand grammar checks that law with its own service model: `list` + answers from the service rows at the moment it is called, and a change reaches + only subscriptions that exist when it commits. Its 16 histories cross the + subscription outcome (listen, subscribe rejects, reader fails, abort, + cleanup), a service change committed before the subscription, and a real live + query or the core subset boundary. Checkpoints after the demand, after the + service change, after the outcome, and after a later change through the + listener compare list calls, load or query state, status, first-ready rows, + and current rows. Required startup failures reject. Later stream failures + retain rows and report the error, matching the adapter's current policy. +- Once a page and every change event for its rows have arrived, the Collection's + rows equal the service's rows. A page was read before any change that the + listener applies while the page is in flight, so the page skips rows that such + an event touched. Every page row is written as a full-row `update`, which + creates a missing row and replaces a held one, so a page never collides with + an event that core accepted but still holds. The page-event grammar enumerates + 15 histories: eager startup or an on-demand load; an update, delete, or + insert; committed before the list call (on-demand only), while the page is in + flight, or after it applies. It compares the Collection's rows with the + service after the page settles and after a later change. A generated grammar + (`trailbase.page-event`) extends the same law: 258 service rows read in two + pages of 256, cursor or offset paging, one or two overlapping on-demand loads, + and up to four inserts, updates, and deletes among page releases. After every + step a changed key holds the service's value; after the history drains, every + returned or changed key does. Four fixed histories cover an update between + pages, an update then a delete during a page, overlapping loads around an + update, and offset paging with two updates. After the history drains, the + Collection holds exactly the rows that a page returned or an event touched and + that the service still has. The page fence orders events by a sequence that + each sync run counts, so a fresh run never skips a row because of an earlier + run's event. The fence keeps per-key sequences only while a load is in flight + and clears them when the last load ends; no public observation measures that + bound. - Events for a change committed before a list call can arrive after the page - applies, because the stream and the list are separate requests. An - `Insert` event is a full row, so the listener writes it as an `update`: a - late insert for a row that a page already holds no longer tears the stream - down with `DuplicateKeySyncError`. The page-event lane crosses changes - committed before the list with delivery at once, while the page is in - flight, or after it applies, including an insert followed by an update; - the generated lane delivers each change at once or later, in commit order. - Both check that a later change still reaches the listener. A late older - event shows its older value until the newer event that follows it arrives. - The adapter has no row version to order an event against a page, so the - lanes check a row only once every event for it has arrived. + applies, because the stream and the list are separate requests. An `Insert` + event is a full row, so the listener writes it as an `update`: a late insert + for a row that a page already holds no longer tears the stream down with + `DuplicateKeySyncError`. The page-event grammar crosses changes committed + before the list with delivery at once, while the page is in flight, or after + it applies, including an insert followed by an update; the generated grammar + delivers each change at once or later, in commit order. Both check that a + later change still reaches the listener. A late older event shows its older + value until the newer event that follows it arrives. The adapter has no row + version to order an event against a page, so the grammars check a row only + once every event for it has arrived. - A page never fails a load for an older event that core accepted but holds - while a user mutation persists. TrailBase mutation handlers wait for their - own change event, so core holds sync commits for the round trip of every - mutation. Four histories cross a persisting mutation (or none) with an - older insert or update event queued before the list call and a page that - lists a newer value. While the mutation holds commits the load stays - pending; once the newer event and the mutation settle, the load is - fulfilled and the rows equal the service. + while a user mutation persists. TrailBase mutation handlers wait for their own + change event, so core holds sync commits for the round trip of every mutation. + Four histories cross a persisting mutation (or none) with an older insert or + update event queued before the list call and a page that lists a newer value. + While the mutation holds commits the load stays pending; once the newer event + and the mutation settle, the load is fulfilled and the rows equal the service. - The adapter subscribes once per sync run and does not resubscribe. After the - stream closes or fails, the Collection keeps its rows and stays ready, a - later service change does not reach it, and a later on-demand load lists - the service's current rows without waiting. The Collection's node shows an - `error` from the end onward, with a stream failure as its cause, and only - `reload()` or a restart clears it. Reconnecting is left to the TrailBase - provider. A mutant that reports the end as a `failureReason` fails 29 - lifecycle cells. Fixed tests in `lifecycle.test.ts` (no oracle owner - (tracked in #2264)) cover the reload and aborted-load scenarios below. One - checks that a reload after a failed startup settles on the new subscription's outcome while the Collection still shows - `error`. Another, `a reload of a ready eager Collection`, enumerates 10 - histories (one or two pages; no change, an update, a delete, or an insert - between the runs, or an insert whose event arrives while the reload's last - page is held). The reload keeps the rows (maintainer decision F19: only a - snapshot or the run's own truncate replaces them), so the pages update the - kept rows and the eager load then deletes every held row that it did not - list and that no change event touched. While the last page is held, no - kept row the service still has disappears; after it, the reload resolves - and the rows equal the service's. On the parent core, whose reload replaced - every row at the new run's first commit, the two-page histories fail (the - second page's rows vanish while it is held). Mutants that skip the sweep or - let it delete an event-touched row fail the `delete` and `insert-event` - histories. `an unchanged row survives an on-demand reload` checks the - on-demand reload: replayed pages update the kept rows, an untouched row - survives, and a row the service deleted while no stream listened stays, - because core does not sweep after a reload (snapshot-api §8, issue #2269) - and a page cannot say a row is gone. `an aborted load writes no rows and -its late result changes nothing` checks that a subset load whose signal - aborts while its list is in flight rejects with `AbortError` and writes - no row when the list answers. Two histories make this limit - executable; they do not claim it is the right product behavior. They pin - one stream end followed by one change and one load. A page whose newer - event never arrives because the stream ended keeps the value the fence - allowed; that follows from the same no-resubscribe limit and has no cell. + stream closes or fails, the Collection keeps its rows and stays ready, a later + service change does not reach it, and a later on-demand load lists the + service's current rows without waiting. The Collection's node shows an `error` + from the end onward, with a stream failure as its cause, and only `reload()` + or a restart clears it. Reconnecting is left to the TrailBase provider. A + mutant that reports the end as a `failureReason` fails 29 lifecycle cells. + Fixed tests in `lifecycle.test.ts` (no oracle owner (tracked in #2264)) cover + the reload and aborted-load scenarios below. One checks that a reload after a + failed startup settles on the new subscription's outcome while the Collection + still shows `error`. Another, `a reload of a ready eager Collection`, + enumerates 10 histories (one or two pages; no change, an update, a delete, or + an insert between the runs, or an insert whose event arrives while the + reload's last page is held). The reload keeps the rows (maintainer decision + F19: only a snapshot or the run's own truncate replaces them), so the pages + update the kept rows and the eager load then deletes every held row that it + did not list and that no change event touched. While the last page is held, no + kept row the service still has disappears; after it, the reload resolves and + the rows equal the service's. On the parent core, whose reload replaced every + row at the new run's first commit, the two-page histories fail (the second + page's rows vanish while it is held). Mutants that skip the sweep or let it + delete an event-touched row fail the `delete` and `insert-event` histories. + `an unchanged row survives an on-demand reload` checks the on-demand reload: + replayed pages update the kept rows, an untouched row survives, and a row the + service deleted while no stream listened stays, because core does not sweep + after a reload (snapshot-api §8, issue #2269) and a page cannot say a row is + gone. `an aborted load writes no rows and its late result changes nothing` + checks that a subset load whose signal aborts while its list is in flight + rejects with `AbortError` and writes no row when the list answers. Two + histories make this limit executable; they do not claim it is the right + product behavior. They are fixed histories of one stream end followed by one + change and one load. A page whose newer event never arrives because the stream + ended keeps the value the fence allowed; that follows from the same + no-resubscribe limit and has no cell. - Graceful closure drains buffered events before releasing the reader. - Processing failures cancel an open source. Terminal paths release readers and timers without detached rejections, including same-turn cleanup. @@ -126,30 +124,32 @@ its late result changes nothing` checks that a subset load whose signal fulfilled. Its direct subset promise settles as a canceled no-op after I/O. - Error and detached-rejection observations are append-only through final gate release and cleanup. Epoch tags record when an observation arrives, not the - hidden sync run that caused it. Exact console output checks pin the - adapter's - current compatibility policy, not a general service reporting contract. + hidden sync run that caused it. Exact console output checks cover the + adapter's current compatibility policy, not a general service reporting + contract. -Histories contain one to three Collection sync runs, eager/on-demand modes, delayed startup -and list resolve/reject, zero to eight row edits, five stream endings, and -immediate versus settled cleanup. Thirty-two fixed cases pin the boundaries; -ordinary runs add 50 fixed-seed and 50 fresh-seed histories with shrinking. -Additional controls cover rejecting late cancellation in both modes and mutate -provider-owned list, event, and buffered rows before parsing. Model rows derive -separately from command scalars, so provider mutation cannot change the answer. +Histories contain one to three Collection sync runs, eager/on-demand modes, +delayed startup and list resolve/reject, zero to eight row edits, five stream +endings, and immediate versus settled cleanup. Thirty-two fixed histories cover +the boundaries; ordinary runs add 50 histories from a fixed-seed campaign and 50 +from a random campaign, with shrinking. Additional controls cover rejecting late +cancellation in both modes and mutate provider-owned list, event, and buffered +rows before parsing. Model rows derive separately from command scalars, so +provider mutation cannot change the answer. -This is not a model of filtered subsets, optimistic mutation -acknowledgements, or service reconnects. Offset paging takes updates only: an -insert or delete between pages moves rows across a position boundary at the -service, which the adapter cannot correct. An eager load cannot overlap an -on-demand one, because an eager source has no subset loader. The page lanes -use one change listener per run; more than two concurrent loads and pages -larger than two remain outside the generated grammar. -The generated event phase starts after loading; existing loading-time unit -matrices and the two held-cancellation startup controls remain valuable. Those -controls were both RED when listener failure awaited cancellation, and GREEN -when cancellation was observed separately. The lifecycle driver calls the core subset boundary; -the early-demand lane also drives a real live query. Service-backed E2E coverage remains separate. +This is not a model of filtered subsets, optimistic mutation acknowledgements, +or service reconnects. Offset paging takes updates only: an insert or delete +between pages moves rows across a position boundary at the service, which the +adapter cannot correct. An eager load cannot overlap an on-demand one, because +an eager source has no subset loader. The page-event grammars use one change +listener per run; more than two concurrent loads and pages larger than two +remain outside the generated grammar. The generated event phase starts after +loading; existing loading-time unit matrices and the two held-cancellation +startup controls remain valuable. Those controls were both RED when listener +failure awaited cancellation, and GREEN when cancellation was observed +separately. The lifecycle production driver calls the core subset boundary; the +early-demand grammar also drives a real live query. Service-backed E2E coverage +remains separate. ## Run and replay @@ -167,7 +167,7 @@ TANSTACK_DB_ORACLE_SEED=123 TANSTACK_DB_ORACLE_PATH=0:1 TANSTACK_DB_ORACLE_PROPE ``` Use `TANSTACK_DB_ORACLE_PROPERTY=trailbase.page-event` and `-t 'generated page -histories with a random or replayed seed'` for the page lane. +histories with a random or replayed seed'` for the page-event grammar. Replace the example seed/path with the failure's values. The guarded runner requires a witness that the named property executed with this seed and path; @@ -177,7 +177,7 @@ do not copy its replay parser. ## Evidence against false greens -The early-demand lane fails all 16 histories on the adapter from before the +The early-demand grammar fails all 16 histories on the adapter from before the listener wait (#2115's `main`): each history lists rows before the listener runs. Four mutants of the wait fail it at assertions: @@ -188,7 +188,7 @@ runs. Four mutants of the wait fail it at assertions: | The wait ignores the caller's abort | 2 | | Cleanup does not settle the wait | 2 | -The page-event and stream-end lanes fail on the adapter before this change: +The page-event and stream-end grammars fail on the adapter before this change: an update during the fetch makes the page's insert collide with the row the event wrote (`DuplicateKeySyncError`, so the load rejects), a delete during the fetch brings the row back, and a later load that lists a changed row @@ -200,7 +200,7 @@ collides the same way. | Pages ignore change events in flight | 4 | | Pages always insert | 2 | -The held-commit lane fails on the adapter before this change: with a +The held-commit grammar fails on the adapter before this change: with a mutation persisting, a page's `insert` for a row that a held event inserted is a duplicate of the pending synced row, and the load rejects. Deciding insert or update from the visible rows (`collection.has`) still fails that @@ -220,21 +220,22 @@ The delivery dimension fails on the adapter before the listener change: an insert and its update committed before the list call, delivered after the page applies, make the listener's `insert` collide with the page's newer row. The stream tears down, and a later change never arrives. A listener that -writes `insert` fails both the single-page history and the generated lane's -named replay at that assertion. +writes `insert` fails both the single-page history and the generated grammar's +named fixed history at that assertion. -The generated lane and its four replays all fail on the adapter before the -fence and on the fence-less mutant; both properties shrink to one update while -the first page is in flight. Always inserting survives that lane, because a -page can only meet a different stored value after a missed event, which only -the stream-end lane produces. A wall-clock fence failed the fresh-run cell: an -earlier run's event in the same millisecond hid the row from the new run. -The 10x campaign passed 800 generated page histories. +The generated grammar and its four fixed histories all fail on the adapter +before the fence and on the fence-less mutant; both properties shrink to one +update while the first page is in flight. Always inserting survives that +grammar, because a page can only meet a different stored value after a missed +event, which only the stream-end grammar produces. A wall-clock fence failed the +fresh-run cell: an earlier run's event in the same millisecond hid the row from +the new run. The 10x campaign passed 800 generated page histories. Before the stale-startup fix, both campaigns independently shrank to: cancel a -pending subscription, restart and load a healthy stream, reject the old subscribe. -The old startup canceled the replacement reader. The fixed corpus pins this in -both modes and sends further edits through the replacement stream. +pending subscription, restart and load a healthy stream, reject the old +subscribe. The old startup canceled the replacement reader. The fixed corpus +covers this in both modes and sends further edits through the replacement +stream. Five isolated source-transform mutations were each rejected by the fixed corpus: @@ -248,8 +249,8 @@ Five isolated source-transform mutations were each rejected by the fixed corpus: These targeted mutations test known failure classes, not overall mutation coverage or a proof of completeness. The 10× campaign passed 800 generated -histories (fixed seed 714203, random seed 127535183) plus the 32 corpus cases. -No service-backed E2E run is claimed. +histories (fixed campaign seed 714203, random campaign seed 127535183) plus the +32 corpus cases. No service-backed E2E run is claimed. ## Shared conformance driver diff --git a/packages/trailbase-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts b/packages/trailbase-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts index 588003bde9..3472958170 100644 --- a/packages/trailbase-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/eager-live-source-conformance-oracle.property.test.ts @@ -1,8 +1,11 @@ import { fc } from '@fast-check/vitest' import { describe, it } from 'vitest' import { + oracleFixedSeed, oraclePropertyOptions, oracleReplayTest, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' @@ -48,6 +51,7 @@ async function campaign(options: fc.Parameters<[unknown]>) { const reach = createOracleReach(property, reachStatesFor(driver), { minimum: 5, }) + oracleRoute(oracleRouteOf(options)) await fc.assert(eagerLiveSourceProperty(driver, reach), options) return reach } @@ -56,7 +60,9 @@ describe(`eager live-source conformance: TrailBase`, () => { oracleReplayTest(it)( `conforms on a fixed-seed campaign`, async () => { - const reach = await campaign({ seed: 2118, numRuns: oracleRuns(runs) }) + const reach = await campaign( + oracleFixedSeed(2118, { numRuns: oracleRuns(runs) }), + ) reach.assertReached() }, campaignTimeoutMs, @@ -73,6 +79,7 @@ describe(`eager live-source conformance: TrailBase`, () => { }) declareOracle({ + owns: [`src/trailbase.ts#trailBaseCollectionOptions`], check: [checkEagerLiveSourceHistory, eagerLiveSourceProperty], model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], grammar: [historyArb], diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index d194c3afa8..083210aef7 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -1,7 +1,13 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { expect, it, vi } from 'vitest' import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' -import { oraclePropertyOptions, oracleRuns } from '../../db/tests/oracle-config' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRoute, + oracleRouteOf, + oracleRuns, +} from '../../db/tests/oracle-config' import { declareOracle } from '../../db/tests/oracle-declaration' import { trailBaseCollectionOptions } from '../src/trailbase' import { MockRecordApi } from './mock-record-api' @@ -439,23 +445,27 @@ it.each( corpus.flatMap((entry) => ([`eager`, `on-demand`] as const).map((mode) => ({ ...entry, mode })), ), -)(`preserves lifecycle laws: $mode / $name`, ({ mode, syncRuns }) => - checkLifecycle({ mode, syncRuns }), -) +)(`preserves lifecycle laws: $mode / $name`, ({ mode, syncRuns }) => { + oracleRoute(`case`) + return checkLifecycle({ mode, syncRuns }) +}) it.each([`eager`, `on-demand`] as const)( `releases a late acquired stream even when native cancellation rejects: %s`, - (mode) => - checkLifecycle({ + (mode) => { + oracleRoute(`case`) + return checkLifecycle({ mode, syncRuns: [ { kind: `cancel-subscribe`, late: `resolve`, cancelRejects: true }, live(`close`), ], - }), + }) + }, ) it.each([`list`, `set`, `buffered`] as const)( `rejects provider-owned row corruption at the %s cut`, async (cut) => { + oracleRoute(`calibration`) const scenario: Scenario = { mode: `eager`, syncRuns: [live(`buffered-close`)], @@ -467,13 +477,21 @@ it.each([`list`, `set`, `buffered`] as const)( }) }, ) -fcTest.prop([scenarioArb], { seed: 714_203, numRuns: oracleRuns(50) })( +const scenarioFixedSeed = oracleFixedSeed(714_203, { numRuns: oracleRuns(50) }) +fcTest.prop([scenarioArb], scenarioFixedSeed)( `matches generated lifecycle histories with a fixed seed`, - (scenario) => checkLifecycle(scenario), + (scenario) => { + oracleRoute(oracleRouteOf(scenarioFixedSeed)) + return checkLifecycle(scenario) + }, ) -fcTest.prop([scenarioArb], oraclePropertyOptions(50, `trailbase.lifecycle`))( +const scenarioCampaign = oraclePropertyOptions(50, `trailbase.lifecycle`) +fcTest.prop([scenarioArb], scenarioCampaign)( `matches generated lifecycle histories with a random or replayed seed`, - (scenario) => checkLifecycle(scenario), + (scenario) => { + oracleRoute(oracleRouteOf(scenarioCampaign)) + return checkLifecycle(scenario) + }, ) /** @@ -701,7 +719,10 @@ const earlyDemandPlans: Array = ( it.each(earlyDemandPlans)( `waits for the listener before an early on-demand fetch: $outcome / change=$serviceChange / $consumer`, - (plan) => checkEarlyDemand(plan), + (plan) => { + oracleRoute(`case`) + return checkEarlyDemand(plan) + }, ) /** @@ -735,7 +756,7 @@ it.each(earlyDemandPlans)( * has arrived. A later change to another row then checks that the listener * is still alive. An older event delivered after a newer page can show its * older value until the newer event that follows it arrives; the adapter has - * no row version to order an event against a page, so the lane only checks a + * no row version to order an event against a page, so the page-event grammar only checks a * row once all of its events have arrived. */ type RacePlan = { @@ -883,7 +904,10 @@ const racePlans: Array = ([`eager`, `on-demand`] as const).flatMap( it.each(racePlans)( `keeps a newer change over an older page: $mode / $change / $timing / $delivery`, - (plan) => checkPageEventRace(plan), + (plan) => { + oracleRoute(`case`) + return checkPageEventRace(plan) + }, ) /** @@ -1020,7 +1044,10 @@ const heldPlans: Array = [true, false].flatMap((held) => it.each(heldPlans)( `converges while a mutation holds sync commits: held=$held / $change`, - (plan) => checkHeldPage(plan), + (plan) => { + oracleRoute(`case`) + return checkHeldPage(plan) + }, ) /** @@ -1033,8 +1060,8 @@ it.each(heldPlans)( * The load lists the service's current rows. Because the sync adapter stops * receiving changes, it reports the Collection's `error`, so an application can * see that the rows can grow stale; only a reload or restart subscribes again. This - * lane makes that limit executable; reconnecting is left to the TrailBase - * provider, and the lane does not claim the limit is the right product + * stream-end grammar makes that limit executable; reconnecting is left to the TrailBase + * provider, and the grammar does not claim the limit is the right product * behavior. * * Reference: the same service model. The prediction after the stream ends is @@ -1042,7 +1069,7 @@ it.each(heldPlans)( * * Grammar (exhaustive, 2 histories): the stream closes gracefully or fails * with a read error after the first page applies; then the service changes a - * row, and an on-demand load runs. The lane pins one stream end followed by + * row, and an on-demand load runs. The stream-end grammar covers one stream end followed by * one change and one load. It does not cover several later loads, a page in * flight when the stream ends, or cleanup between them. * @@ -1120,13 +1147,16 @@ async function checkStreamEnd(ending: `close` | `read-error`) { it.each([`close`, `read-error`] as const)( `keeps rows, reports an error, and does not resubscribe after the stream ends: %s`, - (ending) => checkStreamEnd(ending), + (ending) => { + oracleRoute(`case`) + return checkStreamEnd(ending) + }, ) /** * ## Do several pages, several changes, and overlapping loads still converge? * - * Law: the same one as the page-event lane. Once every page and every change + * Law: the same one as the page-event grammar. Once every page and every change * event for a row have arrived, the Collection holds the service's current * value for that row, or no row if the service deleted it. Between steps, a * row that a change event touched never returns to an older value. @@ -1387,8 +1417,8 @@ async function checkPageHistory(history: PageHistory) { } } -// Named replays: each closes one dimension the single-page lane leaves open. -const pageReplays: Array<[string, PageHistory]> = [ +// Named fixed histories: each closes one dimension the single-page grammar leaves open. +const pageFixedHistories: Array<[string, PageHistory]> = [ [ `an insert and its update in transit until after the page`, { @@ -1460,19 +1490,27 @@ const pageReplays: Array<[string, PageHistory]> = [ ], ] -it.each(pageReplays)(`converges: %s`, (_name, history) => - checkPageHistory(history), -) -fcTest.prop([pageHistoryArb], { seed: 205_917, numRuns: oracleRuns(40) })( +it.each(pageFixedHistories)(`converges: %s`, (_name, history) => { + oracleRoute(`fixed history`) + return checkPageHistory(history) +}) +const pageHistoryFixedSeed = oracleFixedSeed(205_917, { + numRuns: oracleRuns(40), +}) +fcTest.prop([pageHistoryArb], pageHistoryFixedSeed)( `converges across generated page histories with a fixed seed`, - (history) => checkPageHistory(history), + (history) => { + oracleRoute(oracleRouteOf(pageHistoryFixedSeed)) + return checkPageHistory(history) + }, ) -fcTest.prop( - [pageHistoryArb], - oraclePropertyOptions(40, `trailbase.page-event`), -)( +const pageHistoryCampaign = oraclePropertyOptions(40, `trailbase.page-event`) +fcTest.prop([pageHistoryArb], pageHistoryCampaign)( `converges across generated page histories with a random or replayed seed`, - (history) => checkPageHistory(history), + (history) => { + oracleRoute(oracleRouteOf(pageHistoryCampaign)) + return checkPageHistory(history) + }, ) /** @@ -1535,10 +1573,15 @@ async function checkFreshRunAfterEvent() { } } -it(`loads a row in a fresh run that an earlier run's event touched`, () => - checkFreshRunAfterEvent()) +it(`loads a row in a fresh run that an earlier run's event touched`, () => { + oracleRoute(`case`) + return checkFreshRunAfterEvent() +}) +// What this oracle owns: the gap report judges these entries, and their +// reference trees within the package, by this file's tests. declareOracle({ + owns: [`src/trailbase.ts#trailBaseCollectionOptions`], check: [ checkLifecycle, checkEarlyDemand, diff --git a/packages/trailbase-db-collection/tests/lifecycle.test.ts b/packages/trailbase-db-collection/tests/lifecycle.test.ts index e330f80fa9..8b50bea70f 100644 --- a/packages/trailbase-db-collection/tests/lifecycle.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle.test.ts @@ -278,7 +278,7 @@ it.each( * row the service deleted while no stream listened stays: that is this * adapter's limit for on-demand reloads. * - * Reference: the same service as the eager reload lane. Between the runs + * Reference: the same service as the eager reload grammar. Between the runs * the service updates row 1 and deletes row 2 with no change event; row 3 * is untouched. Checkpoint: once the reload settles and the replayed load * has applied, row 1 shows its edit, row 3 survives, and row 2 keeps its diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index ead1e1c23c..9185fd2f05 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -272,7 +272,7 @@ const vueDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - knownGaps: [], + limits: [], features: { serverSnapshot: false, suspense: false, pooledEqFilters: true }, } diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index 9bc8190393..d44803a874 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -278,7 +278,7 @@ const vueInfiniteDriver: InfiniteQueryDriver = { mountCollectionControllable, mountConfigControllable, mountInputControllable, - knownGaps: [], + limits: [], } runInfiniteQuerySuite(vueInfiniteDriver, registerInfiniteQueryUnitScenarios) diff --git a/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts b/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts index a795ba213d..38a5770778 100644 --- a/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts +++ b/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts @@ -31,6 +31,7 @@ import { createCollection } from '@tanstack/db' import { effectScope, watch } from 'vue' import { expect, it, vi } from 'vitest' import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleRoute } from '../../db/tests/oracle-config' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' @@ -100,6 +101,7 @@ function expectCoherentPublication( it.each(cases)( `publishes one coherent Vue data snapshot for a source $name through $input`, async ({ name, initial, change, input }) => { + oracleRoute(`case`) const source = createCollection( mockSyncCollectionOptions({ id: `vue-publication-${name}-${input}`, @@ -197,4 +199,7 @@ it.each(cases)( }, ) -declareOracle({ check: [expectCoherentPublication] }) +declareOracle({ + owns: [`src/useLiveQuery.ts#useLiveQuery`], + check: [expectCoherentPublication], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5907d29d10..466399ec43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@fast-check/vitest>fast-check': ^4.10.2 chokidar@^4.0.0: 4.0.1 trailbase>uuid: 13.0.2 metro: 0.82.5 @@ -59,6 +60,12 @@ importers: '@tanstack/eslint-config': specifier: 0.3.4 version: 0.3.4(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@tanstack/eslint-plugin-oracles': + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@98a9610 + version: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@98a9610(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + '@tanstack/oracles': + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610 + version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610(fast-check@4.10.2)(typescript@5.9.3)(vitest@5.1.0) '@tanstack/typedoc-config': specifier: 0.3.5 version: 0.3.5(typescript@5.9.3) @@ -97,13 +104,13 @@ importers: version: 9.39.5(jiti@2.7.0) eslint-import-resolver-typescript: specifier: ^4.4.5 - version: 4.4.5(eslint-plugin-import-x@4.18.0(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) + version: 4.4.5(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.5(jiti@2.7.0)) fast-check: - specifier: ^3.23.2 - version: 3.23.2 + specifier: ^4.10.2 + version: 4.10.2 husky: specifier: ^9.1.7 version: 9.1.7 @@ -1305,6 +1312,9 @@ importers: '@tanstack/config': specifier: ^0.22.2 version: 0.22.2(@types/node@25.2.2)(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(rollup@4.64.12)(typescript@5.9.3)(vite@8.3.6(@types/node@25.2.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + '@tanstack/oracles': + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610 + version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610(fast-check@4.10.2)(typescript@5.9.3)(vitest@5.1.0) '@vitest/coverage-istanbul': specifier: ^5.1.0 version: 5.1.0(vitest@5.1.0) @@ -1330,8 +1340,8 @@ importers: specifier: workspace:* version: link:../db fast-check: - specifier: ^3.23.2 - version: 3.23.2 + specifier: ^4.10.2 + version: 4.10.2 devDependencies: '@fast-check/vitest': specifier: ^0.5.0 @@ -4009,15 +4019,6 @@ packages: resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - '@eslint/js@9.39.4': resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6156,12 +6157,6 @@ packages: engines: {node: '>=16.0.0', npm: '>=7.10.0'} hasBin: true - '@stylistic/eslint-plugin@5.10.0': - resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - '@stylistic/eslint-plugin@5.4.0': resolution: {integrity: sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6310,9 +6305,10 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 - '@tanstack/eslint-config@0.5.0': - resolution: {integrity: sha512-ViENA1MY9G9PN+xCRPI4aVUgHWZqQdlhiAWi/Gxw9PP4rvsmlyPYx8wLm6Xw7Mb8coRUvNZt3NxKiyQCeulY7w==} - engines: {node: '>=18'} + '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@98a9610': + resolution: {integrity: sha512-w9EsZmTF28TR5wtxqkH0WVNzig5OPTq9GzwJIGEEMcCdOVBa1l7UcT2v48dSQogv1hO/h2HNJaeXVsMeWnEB1Q==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@98a9610} + version: 0.0.0 + engines: {node: '>=20'} peerDependencies: eslint: ^9.0.0 || ^10.0.0 @@ -6320,6 +6316,19 @@ packages: resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} engines: {node: '>=20.19'} + '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610': + resolution: {integrity: sha512-m08Ktpke2uloqVAvS43bpBC4AxadPvS+30XxLzfA0FdlVIaUlThi70yEm14Av7qmkI2fRmWepsNBvmRdp4Odzg==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610} + version: 0.0.0 + engines: {node: '>=20'} + hasBin: true + peerDependencies: + fast-check: ^4.0.0 + typescript: '>=5.0.0' + vitest: ^4.1.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + '@tanstack/pacer-lite@0.3.0': resolution: {integrity: sha512-+YeHKWX5Zd1Irga2smwAolGtzOcf8VVYhM2QI5XEA5TN58iKKufTjhvrbTCEgLLglR1ynHMGVT/l4m5PkwOdaw==} engines: {node: '>=20'} @@ -7912,9 +7921,6 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} - cjs-module-lexer@2.3.0: - resolution: {integrity: sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==} - cli-cursor@2.1.0: resolution: {integrity: sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==} engines: {node: '>=4'} @@ -8574,9 +8580,6 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - es-module-lexer@3.0.4: - resolution: {integrity: sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -8695,31 +8698,12 @@ packages: eslint-import-resolver-node: optional: true - eslint-plugin-import-x@4.18.0: - resolution: {integrity: sha512-UfxHFmV+f9vwybrNn6LQ1rPzfeDMPGi/dRbqQnCFO/jBzYrujSOyX7jxqBSYr/HwXs/V4XJdEGPvC3Re46nzKg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/utils': ^8.56.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - eslint-import-resolver-node: '*' - peerDependenciesMeta: - '@typescript-eslint/utils': - optional: true - eslint-import-resolver-node: - optional: true - eslint-plugin-n@17.23.1: resolution: {integrity: sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: '>=8.23.0' - eslint-plugin-n@17.24.0: - resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: '>=8.23.0' - eslint-plugin-prettier@5.5.7: resolution: {integrity: sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -9063,9 +9047,9 @@ packages: resolution: {integrity: sha512-mpVW/HLUqwKXJ6F/s0hMgdK1eQfM4ncTHDioNGHWL0WKV3VcZCoZq1NKgboBbAge07aRCO7L48yziKssL59XEQ==} engines: {node: '>=18'} - fast-check@3.23.2: - resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} - engines: {node: '>=8.0.0'} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -9351,10 +9335,6 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} - globals@17.14.0: - resolution: {integrity: sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==} - engines: {node: '>=18'} - globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -11351,8 +11331,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - pure-rand@6.1.0: - resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} qrcode-terminal@0.11.0: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} @@ -12570,13 +12550,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - typescript-eslint@8.72.1: - resolution: {integrity: sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - typescript@5.4.2: resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} engines: {node: '>=14.17'} @@ -12962,12 +12935,6 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 - vue-eslint-parser@10.4.2: - resolution: {integrity: sha512-WMpzulb50kb6snghzoGzS++62wcOszAk/piYcnvzdStM8Wni/BmogYltpoOFv2eK9hDbXxvwFQgnwZGpAoUfpA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - vue@3.5.45: resolution: {integrity: sha512-3cvsib8m9c0vB24+MDKsgcW4+Da4xaBl9Z/5Tbm2pmSIsRPxcil4cdXjXlVsmJ2g64XJo5h+IRd7rsdGT9pZaw==} peerDependencies: @@ -15783,10 +15750,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@10.0.1(eslint@9.39.5(jiti@2.7.0))': - optionalDependencies: - eslint: 9.39.5(jiti@2.7.0) - '@eslint/js@9.39.4': {} '@eslint/js@9.39.5': {} @@ -16483,7 +16446,7 @@ snapshots: '@fast-check/vitest@0.5.0(vitest@5.1.0)': dependencies: - fast-check: 3.23.2 + fast-check: 4.10.2 vitest: 5.1.0(@types/node@25.2.2)(@vitest/coverage-istanbul@5.1.0)(@vitest/ui@5.1.0)(jsdom@27.4.0)(vite@8.3.6(@types/node@25.2.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) '@gar/promise-retry@1.0.3': {} @@ -18444,16 +18407,6 @@ snapshots: '@rollup/rollup-win32-arm64-msvc': 4.44.0 '@rollup/rollup-win32-x64-msvc': 4.44.0 - '@stylistic/eslint-plugin@5.10.0(eslint@9.39.5(jiti@2.7.0))': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) - '@typescript-eslint/types': 8.72.1 - eslint: 9.39.5(jiti@2.7.0) - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - estraverse: 5.3.0 - picomatch: 4.0.7 - '@stylistic/eslint-plugin@5.4.0(eslint@9.39.5(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) @@ -18625,24 +18578,23 @@ snapshots: - supports-color - typescript - '@tanstack/eslint-config@0.5.0(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': + '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@98a9610(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint/js': 10.0.1(eslint@9.39.5(jiti@2.7.0)) - '@stylistic/eslint-plugin': 5.10.0(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/parser': 8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.5(jiti@2.7.0) - eslint-plugin-import-x: 4.18.0(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)) - eslint-plugin-n: 17.24.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - globals: 17.14.0 - typescript-eslint: 8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - vue-eslint-parser: 10.4.2(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - - '@typescript-eslint/utils' - - eslint-import-resolver-node - supports-color - typescript '@tanstack/history@1.162.5': {} + '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@98a9610(fast-check@4.10.2)(typescript@5.9.3)(vitest@5.1.0)': + dependencies: + fast-check: 4.10.2 + vitest: 5.1.0(@types/node@25.2.2)(@vitest/coverage-istanbul@5.1.0)(@vitest/ui@5.1.0)(jsdom@27.4.0)(vite@8.3.6(@types/node@25.2.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.90.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + optionalDependencies: + typescript: 5.9.3 + '@tanstack/pacer-lite@0.3.0': {} '@tanstack/publish-config@0.2.2': @@ -20644,8 +20596,6 @@ snapshots: ci-info@3.9.0: {} - cjs-module-lexer@2.3.0: {} - cli-cursor@2.1.0: dependencies: restore-cursor: 2.0.0 @@ -21216,8 +21166,6 @@ snapshots: es-module-lexer@2.3.2: {} - es-module-lexer@3.0.4: {} - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -21435,7 +21383,7 @@ snapshots: optionalDependencies: unrs-resolver: 1.11.1 - eslint-import-resolver-typescript@4.4.5(eslint-plugin-import-x@4.18.0(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): + eslint-import-resolver-typescript@4.4.5(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0)): dependencies: debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) @@ -21446,7 +21394,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import-x: 4.18.0(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -21474,25 +21422,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import-x@4.18.0(@typescript-eslint/utils@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)): - dependencies: - '@typescript-eslint/types': 8.72.1 - cjs-module-lexer: 2.3.0 - comment-parser: 1.4.1 - debug: 4.4.3 - es-module-lexer: 3.0.4 - eslint: 9.39.5(jiti@2.7.0) - eslint-import-context: 0.1.9(unrs-resolver@1.11.1) - is-glob: 4.0.3 - minimatch: 10.2.6 - semver: 7.8.5 - stable-hash-x: 0.2.0 - unrs-resolver: 1.11.1 - optionalDependencies: - '@typescript-eslint/utils': 8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - eslint-plugin-n@17.23.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) @@ -21508,21 +21437,6 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-n@17.24.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0)) - enhanced-resolve: 5.26.0 - eslint: 9.39.5(jiti@2.7.0) - eslint-plugin-es-x: 7.8.0(eslint@9.39.5(jiti@2.7.0)) - get-tsconfig: 4.10.1 - globals: 15.15.0 - globrex: 0.1.2 - ignore: 5.3.2 - semver: 7.8.5 - ts-declaration-location: 1.0.7(typescript@5.9.3) - transitivePeerDependencies: - - typescript - eslint-plugin-prettier@5.5.7(eslint-config-prettier@10.1.8(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.10): dependencies: eslint: 9.39.5(jiti@2.7.0) @@ -22176,9 +22090,9 @@ snapshots: fake-indexeddb@6.2.8: {} - fast-check@3.23.2: + fast-check@4.10.2: dependencies: - pure-rand: 6.1.0 + pure-rand: 8.4.2 fast-deep-equal@3.1.3: {} @@ -22467,8 +22381,6 @@ snapshots: globals@16.5.0: {} - globals@17.14.0: {} - globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -24572,7 +24484,7 @@ snapshots: punycode@2.3.1: {} - pure-rand@6.1.0: {} + pure-rand@8.4.2: {} qrcode-terminal@0.11.0: {} @@ -26242,17 +26154,6 @@ snapshots: transitivePeerDependencies: - supports-color - typescript-eslint@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/parser@8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.72.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.72.1(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) - eslint: 9.39.5(jiti@2.7.0) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - typescript@5.4.2: {} typescript@5.9.3: {} @@ -26558,18 +26459,6 @@ snapshots: transitivePeerDependencies: - supports-color - vue-eslint-parser@10.4.2(eslint@9.39.5(jiti@2.7.0)): - dependencies: - debug: 4.4.3 - eslint: 9.39.5(jiti@2.7.0) - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - semver: 7.8.5 - transitivePeerDependencies: - - supports-color - vue@3.5.45(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.45 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09e6ee05c6..ce502eacaa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,9 @@ packages: - examples/**/* overrides: + # @fast-check/vitest accepts fast-check 3 or 4; one copy (4) keeps test.prop + # campaigns and fast-check's types on the version the oracles use. + '@fast-check/vitest>fast-check': '^4.10.2' # Chokidar 4.0.2 and 4.0.3 fail the trust policy; 4.0.1 satisfies v4 consumers. 'chokidar@^4.0.0': '4.0.1' # UUID 13.0.2 includes the security fix and retains trusted publishing evidence. diff --git a/scripts/agent-lint-feedback.mjs b/scripts/agent-lint-feedback.mjs index c8c43cecaa..2732f4d08d 100644 --- a/scripts/agent-lint-feedback.mjs +++ b/scripts/agent-lint-feedback.mjs @@ -42,19 +42,20 @@ */ import { execFileSync, spawnSync } from 'node:child_process' import { existsSync, readFileSync, realpathSync } from 'node:fs' -import { isAbsolute, join, relative, resolve, sep } from 'node:path' +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 from './eslint-plugin-oracle/index.js' -import { resolveImportedTestModule } from './eslint-plugin-oracle/rules/no-unit-test.js' +import oraclePlugin from '@tanstack/eslint-plugin-oracles' +import { resolveImportedTestModule } from '@tanstack/eslint-plugin-oracles/rules/no-unit-test' import { describeLoweredRunBudget, isOracleFile, loweredRunBudgetsBetween, mergeBase, } from './check-oracle-run-counts.mjs' +import { oracleLintSettings } from './oracle-lint-settings.mjs' const { values: options, positionals } = parseArgs({ options: { @@ -158,21 +159,69 @@ function reachesEdited(oracle, edited) { return false } -const pluginSources = join(root, `scripts`, `eslint-plugin-oracle`) + sep +// The lint configuration: the settings module configures the plugin for this +// repository, and the lockfile pins @tanstack/eslint-plugin-oracles. +const lintSettings = join(root, `scripts`, `oracle-lint-settings.mjs`) +const lockfile = join(root, `pnpm-lock.yaml`) +const pluginName = `@tanstack/eslint-plugin-oracles` + +/** + * The lockfile's entries for the plugin: each line that names it, with the + * lines indented under it. They are its importer entry, its resolution, and + * its snapshot with its resolved dependencies, so they change exactly when an + * install would put another copy of the plugin or of its direct dependencies + * in place. + */ +function pluginEntries(text) { + const lines = text.split(`\n`) + const entries = [] + for (let index = 0; index < lines.length; index++) { + if (!lines[index].includes(pluginName)) continue + const indent = lines[index].search(/\S/) + entries.push(lines[index]) + while (lines[index + 1]?.search(/\S/) > indent) entries.push(lines[++index]) + } + return entries.join(`\n`) +} + +/** + * Whether the plugin's lockfile entries differ from the merge-base's. + * `mergeBase` fails without a main branch, as `changedFiles` does. A merge-base + * without a lockfile has no plugin entries; any other git failure throws. The + * lockfile is larger than a child process's default 1 MiB output buffer, so + * its text is read with no limit. + */ +function pluginChanged() { + const base = mergeBase(root) + const tracked = git(`ls-tree`, `--name-only`, base, `--`, `pnpm-lock.yaml`) + const before = + tracked === `` + ? `` + : execFileSync(`git`, [`show`, `${base}:pnpm-lock.yaml`], { + cwd: root, + encoding: `utf8`, + maxBuffer: Infinity, + }) + const current = existsSync(lockfile) ? readFileSync(lockfile, `utf8`) : `` + return pluginEntries(before) !== pluginEntries(current) +} /** * The oracle files that a lint of `files` must cover. The rule reads relative * imports of test modules, so an oracle file is covered when it is named, or * when it reaches a named file through test modules. Production files select - * nothing, because the rule does not follow them. A change to the rule's own - * sources changes every oracle result, so it selects every oracle file. + * nothing, because the rule does not follow them. A change to the lint + * configuration changes every oracle result, so it selects every oracle file: + * an edit to the settings, or a lockfile whose plugin entries differ from the + * merge-base's. Another lockfile edit, such as an unrelated dependency bump, + * installs the same rule, so it selects nothing. */ function affectedOracleFiles(files) { const edited = new Set(files) const oracles = new Set( [...oracleFilesInTree(), ...files].filter((file) => isOracleFile(file)), ) - if ([...edited].some((file) => file.startsWith(pluginSources))) + if (edited.has(lintSettings) || (edited.has(lockfile) && pluginChanged())) return [...oracles] return [...oracles].filter((oracle) => reachesEdited(oracle, edited)) } @@ -238,6 +287,7 @@ function createLinter() { languageOptions: { parser: tsParser }, }, { linterOptions: { noInlineConfig: true } }, + { settings: oracleLintSettings }, // `lint` passes only oracle files, chosen by `isOracleFile`, so the rules // apply to every file that it passes. { diff --git a/scripts/check-dist-probes.mjs b/scripts/check-dist-probes.mjs new file mode 100644 index 0000000000..acec5d811e --- /dev/null +++ b/scripts/check-dist-probes.mjs @@ -0,0 +1,54 @@ +// Fails when a package's built JavaScript still carries an oracle state probe, +// or, in a package that wires scripts/strip-oracle-probes.mjs, a surviving guard +// (`process.env.NODE_ENV`). scripts/strip-oracle-probes.mjs removes the probes +// and guards from each package build that wires it; this check also catches a +// package whose sources gain a probe without that plugin. An unwired package +// may read `process.env.NODE_ENV` for its own reasons, so the guard check does +// not apply to it. Run it after `pnpm run build`, before anything publishes the +// packages (maintainer decision, 2026-11-09). +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const packages = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + `../packages`, +) +// A package wires the plugin when its vite config names it. +const wiresPlugin = (name) => { + const config = path.join(packages, name, `vite.config.ts`) + return ( + existsSync(config) && + readFileSync(config, `utf8`).includes(`stripOracleProbes`) + ) +} +const carriers = readdirSync(packages).flatMap((name) => { + const dist = path.join(packages, name, `dist`) + let files + try { + files = readdirSync(dist, { recursive: true }) + } catch (error) { + if (error.code === `ENOENT`) return [] + throw error + } + const guardCheck = wiresPlugin(name) + return files + .filter((file) => /\.[cm]?js$/.test(file)) + .map((file) => path.join(dist, file)) + .filter((file) => { + const text = readFileSync(file, `utf8`) + return ( + text.includes(`__oracleProbe`) || + (guardCheck && text.includes(`process.env.NODE_ENV`)) + ) + }) +}) +if (carriers.length > 0) { + console.error( + `These built files carry oracle state probes, or a guard that the plugin should have removed (process.env.NODE_ENV in a package that wires stripOracleProbes()). Add stripOracleProbes() from scripts/strip-oracle-probes.mjs to the package's vite.config.ts, or remove the probe:\n${carriers + .map((file) => ` ${path.relative(process.cwd(), file)}`) + .join(`\n`)}`, + ) + process.exit(1) +} +console.log(`No built package file carries an oracle state probe.`) diff --git a/scripts/check-oracle-run-counts.mjs b/scripts/check-oracle-run-counts.mjs index fa4108338e..2ba004fb82 100644 --- a/scripts/check-oracle-run-counts.mjs +++ b/scripts/check-oracle-run-counts.mjs @@ -13,9 +13,12 @@ * end of a turn. * * A budget is keyed by its registered property where the code names one - * (`oraclePropertyOptions(n, 'p')`, `oracleRandomParameters(n, _, 'p')`, or - * `{ property: 'p', defaultRuns: n }`), and otherwise by its fixed seed - * (`{ seed: s, numRuns: oracleRuns(n) }`). A key or count is a literal, a + * (`oraclePropertyOptions(n, 'p')`, `oracleRandomParameters(n, _, 'p')`, + * `{ property: 'p', defaultRuns: n }`, or the framework definition + * `oracleProperty({ id: 'p', histories: { runs: n } })`), and otherwise by its + * fixed seed (`{ seed: s, numRuns: oracleRuns(n) }`, its declared form + * `oracleFixedSeed(s, { numRuns: oracleRuns(n) })`, or a seed `s` in that + * definition's `fixedSeeds`, whose campaign runs the same `n`). A key or count is a literal, a * constant in the same file, or a constant that the file imports. An import * follows relative re-exports (`export { x } from`, `export * from`), so a * constant defined in a support module is tracked where the oracle uses it. @@ -317,6 +320,47 @@ function runBudgets(file, revision, reader) { if (typeof property === `string`) record(`property ${property}`, value(first)) } + // A framework definition declares its budget inside its histories. + if ( + node.callee.name === `oracleProperty` && + first?.type === `ObjectExpression` + ) { + const property = propertyValue(first, `id`, file, reader, revision) + const histories = first.properties.find( + (candidate) => + candidate.type === `Property` && + !candidate.computed && + nameOf(candidate.key) === `histories`, + )?.value + if (histories?.type === `ObjectExpression`) { + const runs = propertyValue(histories, `runs`, file, reader, revision) + if (typeof property === `string`) record(`property ${property}`, runs) + // Each fixed-seed campaign runs the same `runs` histories. + const fixedSeeds = histories.properties.find( + (candidate) => + candidate.type === `Property` && + !candidate.computed && + nameOf(candidate.key) === `fixedSeeds`, + )?.value + if (fixedSeeds?.type === `ArrayExpression`) + for (const element of fixedSeeds.elements) { + const seed = value(element) + if (typeof seed === `number`) record(`fixed seed ${seed}`, runs) + } + } + } + // A declared fixed seed: `oracleFixedSeed(s, { numRuns: n })`. + if ( + node.callee.name === `oracleFixedSeed` && + second?.type === `ObjectExpression` + ) { + const seed = value(first) + if (typeof seed === `number`) + record( + `fixed seed ${seed}`, + propertyValue(second, `numRuns`, file, reader, revision), + ) + } if (node.callee.name === `oracleRandomParameters`) { const property = value(third) if (typeof property === `string`) diff --git a/scripts/demand-coverage-mutants.mjs b/scripts/demand-coverage-mutants.mjs index 98aa293697..bcf7bb2151 100644 --- a/scripts/demand-coverage-mutants.mjs +++ b/scripts/demand-coverage-mutants.mjs @@ -1,4 +1,4 @@ -// Hostile mutants for persisted demand coverage. +// Mutants for persisted demand coverage. // // Each mutant replaces one exact piece of production code with a plausible // wrong design. The harness applies it in memory through diff --git a/scripts/eslint-plugin-oracle/index.js b/scripts/eslint-plugin-oracle/index.js deleted file mode 100644 index d6a38d34da..0000000000 --- a/scripts/eslint-plugin-oracle/index.js +++ /dev/null @@ -1,45 +0,0 @@ -import noDisabled from './rules/no-disabled.js' -import noUnitTest from './rules/no-unit-test.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 TypeScript and JavaScript 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. - */ - -// The same names that scripts/check-oracle-run-counts.mjs `isOracleFile` accepts. -const oracleFiles = [ - `**/*oracle*.ts`, - `**/*oracle*.tsx`, - `**/*oracle*.js`, - `**/*oracle*.mjs`, - `**/*oracle*.jsx`, -] - -const plugin = { - meta: { name: `eslint-plugin-oracle` }, - rules: { - 'no-disabled': noDisabled, - 'no-unit-test': noUnitTest, - }, -} - -plugin.configs = { - recommended: { - name: `oracle/recommended`, - files: oracleFiles, - plugins: { oracle: plugin }, - rules: { - 'oracle/no-disabled': `error`, - 'oracle/no-unit-test': `error`, - }, - }, -} - -export default plugin diff --git a/scripts/eslint-plugin-oracle/no-disabled.test.js b/scripts/eslint-plugin-oracle/no-disabled.test.js deleted file mode 100644 index 7c17a7820f..0000000000 --- a/scripts/eslint-plugin-oracle/no-disabled.test.js +++ /dev/null @@ -1,547 +0,0 @@ -import { describe, it } from 'vitest' -import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -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 }, -}) - -it(`permits selecting only the replay observation checker controls`, () => { - const root = fileURLToPath(new URL('../../', import.meta.url)) - // Vitest 5 writes the JSON report to a file and prints a status line, so - // the report is read from an output file rather than stdout. - const directory = mkdtempSync(join(tmpdir(), `oracle-json-report-`)) - const outputFile = join(directory, `report.json`) - try { - const result = spawnSync( - process.execPath, - [ - join(root, 'node_modules/vitest/vitest.mjs'), - 'run', - '--config', - 'scripts/eslint-plugin-oracle/vitest.config.mjs', - 'scripts/eslint-plugin-oracle/replay-observation-oracle.test.js', - '-t', - 'rejects wrong report observation', - '--reporter=json', - `--outputFile=${outputFile}`, - ], - { - cwd: root, - encoding: 'utf8', - timeout: 10_000, - maxBuffer: 16 * 1024 * 1024, - }, - ) - assert.equal(result.error, undefined) - assert.equal(result.status, 0, result.stdout + result.stderr) - const report = JSON.parse(readFileSync(outputFile, 'utf8')) - assert.equal(report.numPassedTests, 2) - assert.equal(report.numFailedTests, 0) - assert.equal(report.numPendingTests, 3848) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -}) - -ruleTester.run(`oracle/no-disabled`, noDisabled, { - valid: [ - // A selection helper's result used as a registration, with no modifier. - { - code: `import { oracleReplayTest } from './oracle-config.js'\nimport { it as vitestIt } from 'vitest'\nconst fixedCase = oracleReplayTest(vitestIt)\nfixedCase('checks the fixed history', () => check(history))`, - languageOptions: { sourceType: `module` }, - }, - // A constant that is not bound to a test function registers nothing. - { - code: `const helper = makeHelper()\nhelper.skip('a', () => check())`, - languageOptions: { sourceType: `module` }, - }, - // The shared replay variables, and variables that select no replay. - `const seed = process.env.TANSTACK_DB_ORACLE_SEED`, - `const { TANSTACK_DB_ORACLE_PATH, TANSTACK_DB_ORACLE_PROPERTY } = process.env`, - `const statistics = process.env.TANSTACK_DB_ORACLE_STATISTICS`, - `const debug = process.env.RELOAD_ORACLE_DEBUG`, - `const seed = config.TANSTACK_DB_LIKE_ORACLE_SEED`, - // oracle-config implements replay selection, so it reads every variable. - { - code: `const seed = process.env.UTILS_ORACLE_SEED`, - filename: `/repo/packages/db/tests/oracle-config.ts`, - }, - // 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 for a control that one browser supports. - `test.skip(browserName !== 'chromium', 'Chromium-only control')`, - // A replay campaign stands the other tests aside. - { - 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) })`, - `const BINDING_RUNS = 12\nfc.assert(property, { numRuns: oracleRuns(BINDING_RUNS) })`, - `const fixedSeed = 7\nconst runs = 30\nfc.assert(property, { seed: fixedSeed, numRuns: runs })`, - // 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: -1659005, numRuns: 1 })`, - `const fixedSeed = 1657019\nfc.check(property, { seed: fixedSeed, numRuns: 1 })`, - `fc.assert(property, { seed: 2128, numRuns: oracleRuns(1) })`, - // 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 })`, - { - code: `const witness: OracleReplayWitness = { numRuns: 1 }; appendFileSync(channel, JSON.stringify(witness))`, - filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, - }, - { - code: `const witness: OracleReplayWitness = { numRuns: 1 }; type Snapshot = typeof witness; appendFileSync(channel, JSON.stringify(witness)); type Count = typeof witness.numRuns`, - filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, - }, - { - code: `type JSON = { marker: true }; const witness: OracleReplayWitness = { numRuns: 1 }; JSON.stringify(witness)`, - filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, - }, - // 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()) { 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) => ({ - 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') } })`, - // 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)) }`, - // A loop over a table that a replay helper empties during a replay, and a - // data filter, run every item in ordinary runs. - ...[ - `for (const c of oracleReplayRequested() ? [] : cases) { it(c.name, () => check(c)) }`, - `it.each(cases.filter((c) => c.legal))('%s', (c) => check(c))`, - `const campaigns = oracleReplaySkips('cursor.exact-width') ? [] : cases\nfor (const c of campaigns) { it(c.name, () => check(c)) }`, - `const registration = oracleReplayTest(it)\nregistration('a', () => check())`, - ].map((code) => ({ - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\n${code}`, - languageOptions: { sourceType: `module` }, - })), - // 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. - `it('reads a page', (ctx) => { pages.forEach((ctx) => ctx.skip()) })`, - `it('reads a page', (_, ctx) => { ctx.skip() })`, - ], - invalid: [ - ...[ - `fc.assert(property, { numRuns: 1 })`, - `const witness: OracleReplayWitness = { numRuns: 1 }; appendFileSync(channel, JSON.stringify(witness)); fc.assert(property, witness)`, - `const witness: OracleReplayWitness = { numRuns: 1 }; const options = witness; fc.assert(property, options)`, - `export const witness: OracleReplayWitness = { numRuns: 1 }; JSON.stringify(witness)`, - `function outer(JSON) { { type JSON = { marker: true }; const witness: OracleReplayWitness = { numRuns: 1 }; JSON.stringify(witness) } }`, - `const budgetHook = { toJSON() { fc.assert(property, this); return null } }; const witness: OracleReplayWitness = { ...budgetHook, numRuns: 1 }; JSON.stringify(witness)`, - `const budgetHook = { toJSON() { fc.assert(property, this); return null } }; const witness: OracleReplayWitness = { __proto__: budgetHook, numRuns: 1 }; JSON.stringify(witness)`, - ].map((code) => ({ - code, - filename: `/repo/packages/db/tests/oracle-replay-witness.ts`, - errors: [{ messageId: `singleRun` }], - })), - { - code: `const witness: OracleReplayWitness = { numRuns: 1 }; appendFileSync(channel, JSON.stringify(witness))`, - filename: `/repo/packages/db/tests/other-output.ts`, - errors: [{ messageId: `singleRun` }], - }, - // A private variable that selects a replay or a run count. - { - code: `const seed = process.env.TANSTACK_DB_LIKE_ORACLE_SEED`, - errors: [ - { - messageId: `replayVariable`, - data: { name: `TANSTACK_DB_LIKE_ORACLE_SEED` }, - }, - ], - }, - { - code: `fc.assert(property, { path: process.env['BTREE_MAP_ORACLE_PATH'] })`, - errors: [{ messageId: `replayVariable` }], - }, - { - code: `const runs = Number(process.env.TANSTACK_DB_OPFS_CLOSE_ORACLE_RUNS ?? 20)`, - errors: [{ messageId: `replayVariable` }], - }, - { - code: `const { INDEXEDDB_ORACLE_SEED: seed, TANSTACK_DB_ORACLE_PATH } = process.env`, - errors: [ - { - messageId: `replayVariable`, - data: { name: `INDEXEDDB_ORACLE_SEED` }, - }, - ], - }, - { - code: `const property = process.env.UTILS_ORACLE_PROPERTY`, - filename: `/repo/packages/db/tests/utils-oracle.property.test.ts`, - errors: [{ messageId: `replayVariable` }], - }, - { - 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` }], - }, - { - 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` }], - }, - { - 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())`, - 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` }], - }, - { - code: `const propertyTest = replay.replayPath === undefined ? fcTest : fcTest.skip`, - errors: [ - { messageId: `aliasTest`, data: { name: `fcTest` } }, - { 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` }], - }, - { - 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` }], - }, - { - code: `const BINDING_RUNS = 12\nfc.assert(property, { numRuns: BINDING_RUNS })`, - errors: [{ messageId: `literalRuns` }], - }, - { - code: `const replayRuns = 1\nfc.assert(property, { numRuns: replayRuns })`, - errors: [{ messageId: `singleRun` }], - }, - { - code: `const base = 1\nfc.assert(property, oraclePropertyOptions(base, 'p'))`, - errors: [{ messageId: `singleRun` }], - }, - { - // The rule reports the alias, and it also sees the skip through it. - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nconst t = it\nt.skip('a', () => check())`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `aliasTest` }, { messageId: `skip` }], - }, - // A registration through a selection helper's result is a registration of - // its test, so its modifiers and guards are checked. - { - code: `import { oracleReplayTest } from './oracle-config.js'\nconst fixedCase = oracleReplayTest(it)\nfixedCase.skip('checks the fixed history', () => check(history))`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `skip` }], - }, - { - code: `import { oracleReplayTest } from './oracle-config.js'\nconst fixedCase = oracleReplayTest(it)\nfixedCase.only('checks the fixed history', () => check(history))`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `only` }], - }, - { - code: `import { oracleReplayTest } from './oracle-config.js'\nconst fixedCase = oracleReplayTest(it)\nif (process.env.CI) fixedCase('checks the fixed history', () => check(history))`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `guarded` }], - }, - { - code: `import { oracleCalibrationTest } from './oracle-config.js'\nconst witness = oracleCalibrationTest(it, 'p', 'fault')\nconst again = witness\nagain.skipIf(process.env.CI)('rejects the mutant', () => check(bad))`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `conditional` }], - }, - // A renamed import of a test function is that test function. - { - code: `import { it as vitestIt } from 'vitest'\nvitestIt.skip('checks a history', () => check(history))`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `skip` }], - }, - { - code: `import { oracleReplayTest } from './oracle-config.js'\nimport { test as fcTestAlias } from '@fast-check/vitest'\nconst campaign = oracleReplayTest(fcTestAlias, 'p')\ncampaign.todo.prop([history])('checks histories', (h) => check(h))`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `skip` }], - }, - { - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nconst tests = [it, test]\ntests[0]('a', () => check())`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `aliasTest` }, { messageId: `aliasTest` }], - }, - { - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nfor (const c of process.env.CI ? [] : cases) { it(c.name, () => check(c)) }`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `guarded` }], - }, - { - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nfor (const c of cases.filter(() => !process.env.CI)) { it(c.name, () => check(c)) }`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `guarded` }], - }, - { - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nit.each(process.env.CI ? [] : cases)('%s', (c) => check(c))`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `guarded` }], - }, - { - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nconst campaigns = process.env.CI ? [] : cases\nfor (const c of campaigns) { it(c.name, () => check(c)) }`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `guarded` }], - }, - { - code: `import { oracleReplayRequested, oracleReplaySkips, oracleReplayTest } from './oracle-config.js'\nfor (const c of oracleReplayRequested() ? cases : []) { it(c.name, () => check(c)) }`, - languageOptions: { sourceType: `module` }, - errors: [{ messageId: `guarded` }], - }, - { - // 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/no-unit-test.test.js b/scripts/eslint-plugin-oracle/no-unit-test.test.js deleted file mode 100644 index c04ad96995..0000000000 --- a/scripts/eslint-plugin-oracle/no-unit-test.test.js +++ /dev/null @@ -1,460 +0,0 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { describe, it } from 'vitest' -import { RuleTester } from 'eslint' -import tsParser from '@typescript-eslint/parser' -import noUnitTest from './rules/no-unit-test.js' - -RuleTester.describe = describe -RuleTester.it = it - -// Companion modules on disk, so that the rule resolves real imports. -const root = mkdtempSync(join(tmpdir(), `oracle-no-unit-test-`)) -const tests = join(root, `tests`) -mkdirSync(join(root, `src`), { recursive: true }) -mkdirSync(tests, { recursive: true }) -writeFileSync( - join(tests, `history-oracle.ts`), - `import { declareOracle } from './oracle-config.js' -function checkHistory(history) { return history } -function modelHistory(history) { return history } -declareOracle({ check: [checkHistory], model: [modelHistory] }) -export async function runHistory(history) { return checkHistory(history) } -export function fixture() { return [] } -export { modelHistory } -// Every caller here passes work that runs the check, but an importer passes -// its own work. -export async function withCleanup(work) { return work() } -export class Relation { add() {} } -declareOracle({ check: [Relation] }) -it('checks with cleanup', () => withCleanup(() => runHistory([]))) -`, -) -writeFileSync( - join(tests, `history-reexport.ts`), - `export { runHistory as replayHistory } from './history-oracle.js' -export * from './history-oracle.js' -`, -) -writeFileSync( - join(root, `src`, `production.ts`), - `import { declareOracle } from '../tests/oracle-config.js' -function compile(x) { return x } -declareOracle({ check: [compile] }) -export { compile } -`, -) -writeFileSync(join(root, `package.json`), JSON.stringify({ name: `published` })) -// A private package, such as an end-to-end suite, keeps its oracle in src/. -const suite = join(root, `suite`) -mkdirSync(join(suite, `src`), { recursive: true }) -writeFileSync( - join(suite, `package.json`), - JSON.stringify({ name: `suite`, private: true }), -) -writeFileSync( - join(suite, `src`, `laws-oracle.ts`), - `import { declareOracle } from './oracle-config.js' -export function checkLaws(observation) { return observation } -declareOracle({ check: [checkLaws] }) -`, -) -const filename = join(tests, `subject-oracle.test.ts`) -const suiteFilename = join(suite, `src`, `pages-oracle.suite.ts`) - -const header = `import { declareOracle } from './oracle-config.js' -function checkHistory(history) { return history } -function modelHistory(history) { return history } -declareOracle({ check: [checkHistory], model: [modelHistory] }) -` - -const fcImport = `import { fc, test as fcTest } from '@fast-check/vitest'\n` - -const ruleTester = new RuleTester({ - languageOptions: { parser: tsParser, sourceType: `module` }, -}) - -ruleTester.run(`oracle/no-unit-test`, noUnitTest, { - valid: [ - // A generated property reaches the declared check. - { - code: `${header}fcTest.prop([history])('checks generated histories', (h) => checkHistory(h))`, - filename, - }, - // A fixed history reaches the check through a driver. - { - code: `${header}async function run(h) { await checkHistory(h) } -it('retains a row after an update', async () => { await run([{ type: 'update' }]) })`, - filename, - }, - // A driver held in a constant, and a body passed by name. - { - code: `${header}const run = (h) => checkHistory(h) -const replay = () => run([]) -it('replays the pinned history', replay)`, - filename, - }, - // A model pin reaches the declared model. - { - code: `${header}it('keeps an empty diff pending', () => { expect(modelHistory([])).toEqual([]) })`, - filename, - }, - // A table of fixed histories. - { - code: `${header}it.each(cases)('checks %s', (c) => checkHistory(c))`, - filename, - }, - // A calibration witness reaches the check through the selection helper. - { - code: `import { declareOracle, oracleCalibrationTest } from './oracle-config.js' -function checkHistory(history) { return history } -declareOracle({ check: [checkHistory] }) -oracleCalibrationTest(it, 'p', 'fault')('rejects the hostile mutant', () => checkHistory(bad))`, - filename, - }, - // A class model. - { - code: `import { declareOracle } from './oracle-config.js' -class Model { step() {} } -function checkHistory(h) { return h } -declareOracle({ check: [checkHistory], model: [Model] }) -it('steps the model', () => { new Model().step() })`, - filename, - }, - // A companion module declares the check, and its driver reaches it. - { - code: `import { runHistory } from './history-oracle.js' -it('checks a fixed history', () => runHistory([]))`, - filename, - }, - // A re-export and an export star keep the reach. - { - code: `import { replayHistory, modelHistory } from './history-reexport.js' -it('replays', () => replayHistory([])) -it('pins the model', () => modelHistory([]))`, - filename, - }, - // A private package's src/ is test code, so the rule follows it. - { - code: `import { checkLaws } from './laws-oracle.js' -it('checks the page laws', () => checkLaws([]))`, - filename: suiteFilename, - }, - // A test of the shared oracle machinery checks no law. - { - code: `import { readOracleRunConfig } from './oracle-config.js' -it('rejects a retired variable', () => expect(() => readOracleRunConfig({})).toThrow())`, - filename: join(tests, `oracle-config.test.ts`), - }, - // A helper that registers the property its caller passes in. - { - code: `${header}function serializerProp(name, check) { - fcTest.prop([value])(name, check) -} -serializerProp('round-trips a value', (v) => checkHistory(v)) -serializerProp('round-trips an array', (v) => checkHistory([v]))`, - filename, - }, - // A grammar calibration reaches the declared grammar. - { - code: `import { declareOracle } from './oracle-config.js' -const historyGrammar = fc.array(action) -function checkHistory(h) { return h } -declareOracle({ check: [checkHistory], grammar: [historyGrammar] }) -it('reaches every action kind', () => { expect(kinds(fc.sample(historyGrammar, 200))).toEqual(allKinds) })`, - filename, - }, - // A generated property is its own check, with or without a declaration. - { - code: `${fcImport}fcTest.prop([fc.integer(), fc.integer()])('compares antisymmetrically', (a, b) => { - expect(Math.sign(compare(a, b))).toBe(-Math.sign(compare(b, a))) -})`, - filename, - }, - { - code: `${fcImport}it('round-trips every value', () => { - fc.assert(fc.property(fc.string(), (value) => expect(decode(encode(value))).toBe(value))) -})`, - filename, - }, - // A property defined elsewhere, and a helper whose callers pass laws. - { - code: `${fcImport}const roundTrip = fc.property(fc.string(), (value) => decode(encode(value)) === value) -it('round-trips', () => fc.assert(roundTrip)) -function utilsProperty(name, arbitraries, check) { fcTest.prop(arbitraries)(name, check) } -utilsProperty('is reflexive', [fc.anything()], (x) => expect(isEqual(x, x)).toBe(true)) -utilsProperty('is symmetric', [fc.anything(), fc.anything()], (x, y) => expect(isEqual(x, y)).toBe(isEqual(y, x)))`, - filename, - }, - // A registration that passes named arbitraries and a named predicate. - { - code: `${fcImport}const arbitraries = [fc.integer(), fc.string()] -async function checkCampaign(id, name) { expect(await publish(id, name)).toBe(name) } -const checkPolicy = async (id) => expect(await persist(id)).toBe(id) -fcTest.prop(arbitraries)('publishes generated commits', checkCampaign) -fcTest.prop(arbitraries)('persists generated commits', checkPolicy)`, - filename, - }, - // A table row that calls a declared check. - { - code: `${header}it.each([{ name: 'stale', reject: () => checkHistory(stale) }])('rejects $name', ({ reject }) => expect(reject).toThrow())`, - filename, - }, - // A named property predicate that reads its input. - { - code: `${fcImport}function roundTrips(value) { expect(decode(encode(value))).toBe(value) } -it('round-trips every value', () => fc.assert(fc.property(fc.string(), roundTrips)))`, - filename, - }, - // An exported class declared as a check in its home module. - { - code: `import { Relation } from './history-oracle.js' -it('adds a row', () => { new Relation().add() })`, - filename, - }, - // A type-level test that compares with a declared type model. - { - code: `import { declareOracle } from './oracle-config.js' -type PublishedValueFor = T extends { id: unknown } ? T & { $key: string } : T -declareOracle({ typeModel: [\`PublishedValueFor\`] }) -type PublishedRow = PublishedValueFor -test('publishes row roots', () => { - expectTypeOf(rowOf(query)).toEqualTypeOf>() -}) -test('publishes a named row root', () => { - expectTypeOf(rowOf(query)).toEqualTypeOf() -})`, - filename, - }, - // declareOracle can come from the dependency-free oracle-declaration module. - { - code: `import { declareOracle } from './oracle-declaration.js' -function checkHistory(history) { return history } -declareOracle({ check: [checkHistory] }) -it('checks a fixed history', () => checkHistory([]))`, - filename, - }, - // A coverage calibration reads a Set that grammar-driven tests fill with - // the model's predictions. - { - code: `${header}const covered = new Set() -it.each(cases)('runs %s', (c) => { covered.add(modelHistory(c)); checkHistory(c) }) -it('covers every outcome', () => expect([...covered]).toEqual(['a', 'b']))`, - filename, - }, - // A placeholder registered during a replay runs nothing. - { - code: `it.skip('runs only when its replay property is selected', () => {})`, - filename, - }, - // A suite and a Playwright step are not tests. - { - code: `${header}describe('suite', () => { - test('runs a browser history', async () => { await test.step('open', async () => {}); checkHistory([]) }) -})`, - filename, - }, - // A file with no tests needs no declaration. - { code: `export function fixture() { return [] }`, filename }, - ], - invalid: [ - // The reported failure: production compared with a hand-written value. - { - code: `${header}import { compileExpression } from '../src/compiler.js' -fcTest.prop([history])('checks generated histories', (h) => checkHistory(h)) -it('handles a simple table reference', () => { - expect(compileExpression(ref)(row)).toEqual({ id: 1 }) -})`, - filename, - errors: [{ messageId: `unitTest`, line: 7 }], - }, - // A unit test registered through a selection helper's result, or - // through a renamed import, is still a test. - { - code: `${header}import { oracleReplayTest } from './oracle-config.js' -import { it as vitestIt } from 'vitest' -const fixedCase = oracleReplayTest(vitestIt) -fixedCase('checks', () => checkHistory([])) -fixedCase('compiles', () => expect(compile(1)).toBe(1)) -vitestIt('compiles again', () => expect(compile(2)).toBe(2))`, - filename, - errors: [ - { messageId: `unitTest`, line: 9 }, - { messageId: `unitTest`, line: 10 }, - ], - }, - // A file with tests and no declaration gets one report. - { - code: `import { compile } from '../src/compiler.js' -it('a', () => expect(compile(1)).toBe(1)) -it('b', () => expect(compile(2)).toBe(2))`, - filename, - errors: [{ messageId: `undeclared`, line: 2 }], - }, - // A local function named declareOracle declares nothing. - { - code: `function declareOracle() {} -function checkHistory(h) { return h } -declareOracle({ check: [checkHistory] }) -it('checks', () => checkHistory([]))`, - filename, - errors: [{ messageId: `undeclared` }], - }, - // A production module declares no oracle part, even when it calls the - // helper, so the rule does not follow it. - { - code: `${header}import { compile } from '../src/production.js' -it('checks', () => checkHistory([])) -it('compiles', () => expect(compile(1)).toBe(1))`, - filename, - errors: [{ messageId: `unitTest`, line: 7 }], - }, - // A local binding that shadows the check is not the check. - { - code: `${header}it('checks', () => { const checkHistory = (x) => x; expect(checkHistory(1)).toBe(1) })`, - filename, - errors: [{ messageId: `unitTest` }], - }, - // A helper that only uses itself reaches nothing. - { - code: `${header}function loop(n) { return n === 0 ? 0 : loop(n - 1) } -it('counts down', () => expect(loop(3)).toBe(0))`, - filename, - errors: [{ messageId: `unitTest` }], - }, - // A type annotation runs nothing, so it does not reach the check. - { - code: `${header}it('types', () => { const check: typeof checkHistory | undefined = undefined; expect(check).toBeUndefined() })`, - filename, - errors: [{ messageId: `unitTest` }], - }, - // A companion module's fixture does not reach its check. - { - code: `import { runHistory, fixture } from './history-oracle.js' -it('checks a fixed history', () => runHistory([])) -it('builds a fixture', () => expect(fixture()).toEqual([]))`, - filename, - errors: [{ messageId: `unitTest`, line: 3 }], - }, - // One caller that passes a unit test makes the helper's test a unit test. - { - code: `${header}const serializerProp = (name, check) => { - fcTest.prop([value])(name, check) -} -serializerProp('round-trips a value', (v) => checkHistory(v)) -serializerProp('encodes a date', () => expect(encode(date)).toBe('x'))`, - filename, - errors: [{ messageId: `unitTest`, line: 6 }], - }, - // A helper that nothing calls by name passes nothing the rule can see. - { - code: `${header}export function register(name, check) { it(name, check) }`, - filename, - errors: [{ messageId: `unitTest` }], - }, - // A property over constants, or one that ignores its input, checks - // written values. - { - code: `${fcImport}fcTest.prop([fc.constant(1)])('compiles one', (n) => expect(compile(n)).toBe(1))`, - filename, - errors: [{ messageId: `undeclared` }], - }, - { - code: `${fcImport}fcTest.prop([fc.integer()])('encodes a date', () => expect(encode(date)).toBe('x'))`, - filename, - errors: [{ messageId: `undeclared` }], - }, - { - code: `${fcImport}it('encodes a date', () => { - fc.assert(fc.property(fc.integer(), () => expect(encode(date)).toBe('x'))) -})`, - filename, - errors: [{ messageId: `undeclared` }], - }, - { - code: `${header}${fcImport}function utilsProperty(name, arbitraries, check) { fcTest.prop(arbitraries)(name, check) } -utilsProperty('is reflexive', [fc.anything()], (x) => expect(isEqual(x, x)).toBe(true)) -utilsProperty('encodes a date', [fc.integer()], () => expect(encode(date)).toBe('x')) -it('checks', () => checkHistory([]))`, - filename, - errors: [{ messageId: `unitTest`, line: 6 }], - }, - // A named predicate that ignores its input checks one written value. - { - code: `${fcImport}function encodesDate() { expect(encode(date)).toBe('x') } -fcTest.prop([fc.integer()])('encodes a date', encodesDate)`, - filename, - errors: [{ messageId: `undeclared` }], - }, - // A table of written values reaches nothing. - { - code: `${header}it.each([[1, 2]])('adds %i', (a, b) => expect(add(a, b)).toBe(3))`, - filename, - errors: [{ messageId: `unitTest` }], - }, - // Iterating the declared grammar's cells does not reach it: the body must. - { - code: `import { declareOracle } from './oracle-config.js' -const cells = [{ a: 1 }] -function checkHistory(h) { return h } -declareOracle({ check: [checkHistory], grammar: [cells] }) -it.each(cells)('handles $a', ({ a }) => expect(compile(a)).toBe(1))`, - filename, - errors: [{ messageId: `unitTest` }], - }, - // An imported helper that reaches a part only through its home module's - // arguments does not reach one for the importer. - { - code: `import { withCleanup } from './history-oracle.js' -it('compiles', () => withCleanup(() => expect(compile(1)).toBe(1)))`, - filename, - errors: [{ messageId: `undeclared` }], - }, - // A named property predicate that ignores its input checks one value. - { - code: `${fcImport}const ignores = () => expect(encode(date)).toBe('x') -it('encodes a date', () => { - fc.assert(fc.property(fc.integer(), ignores)) -})`, - filename, - errors: [{ messageId: `undeclared` }], - }, - // A type test that names no type model, and a name that is not a type. - { - code: `import { declareOracle } from './oracle-config.js' -type PublishedValueFor = T -declareOracle({ typeModel: ['PublishedValueFor', 'MissingModel'] }) -test('publishes row roots', () => { - expectTypeOf(rowOf(query)).toEqualTypeOf>() -}) -test('keeps a number', () => { - expectTypeOf(valueOf(query)).toEqualTypeOf() -})`, - filename, - errors: [ - { messageId: `unknownTypeModel`, line: 3 }, - { messageId: `unitTest`, line: 7 }, - ], - }, - // A Set filled with written values carries no declared part. - { - code: `${header}const seen = new Set() -it('records', () => { seen.add('a') }) -it('reads', () => expect([...seen]).toEqual(['a']))`, - filename, - errors: [ - { messageId: `unitTest`, line: 6 }, - { messageId: `unitTest`, line: 7 }, - ], - }, - // A test inside a suite and a test passed a timeout are still tests. - { - code: `${header}describe('suite', () => { - it('a', () => expect(1).toBe(1), 1000) -})`, - filename, - errors: [{ messageId: `unitTest` }], - }, - ], -}) diff --git a/scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js b/scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js deleted file mode 100644 index e78288a9ea..0000000000 --- a/scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js +++ /dev/null @@ -1,2391 +0,0 @@ -/** - * Law: every test registration in an oracle file reaches a declared part, or - * the rule reports it. A declared part is a check, a model, a grammar, or a - * type model. A registration reaches a part when its body uses one, directly - * or through a binding that reaches one, through local functions, imports, - * re-exports, and companion modules. A registration whose `.each` or `.for` - * table has a function that reaches a part reaches it too. - * - * A registration that reaches none is reported as a unit test. A file that - * declares no part and has no reaching test gets one `undeclared` report. An - * unparseable companion reaches nothing, and an oracle file that calls a helper - * that registers tests of its own gets one report for the call, unless the - * helper's module is linted on its own. - * - * A binding is reached however it is bound: a named, namespace, or default - * import; a `let` that a hook assigns; or a `for...of` loop variable, which - * runs one test per element, so every element must reach. A helper that - * registers its caller's body, directly or wrapped in a function of its own, - * is judged at the call by the argument it registers. The test also reaches - * when the wrapper itself uses a part. A generated property counts as its own - * check when one of its arbitraries generates values and its predicate reads - * its input. The arbitrary may be named, and the predicate may be imported. - * - * Authority: the header contract of `rules/no-unit-test.js`, which states the - * reach rules and the report rules, and its laws L1 (reports are a pure - * function of the source files), L2 (every registration form is checked), L3 - * (every declared part counts), and L6 (an oracle file registers only oracle - * tests, including tests that its imported functions register); - * docs/contributing/oracle-rubric.md "Name oracle files consistently", which - * says that an oracle-named helper "is linted on its own"; and AGENTS.md "Name - * Oracle Files for Discovery". `export *` does not re-export a default export - * (ECMAScript, ExportDeclaration semantics), so a default import resolves only - * a module's own default. - * - * Limits. The grammar does not generate function parameters other than a - * helper's, `describe` placeholders, declared import bindings, type models - * built from other models, a `let` assigned more than once, a `for...of` over - * a named array, or a predicate or arbitrary from a package. It builds at most - * three companion modules, three functions per module, and six registrations - * per oracle file. The receiving paths are a long-lived ESLint instance, a - * single-file Linter call, the lint CLI on changed files, and the CLI's - * edit-hook form. The CLI lints only files inside its repository, so its paths - * write scratch files in `part-reach-scratch-*` directories there, which git - * ignores, and remove them afterwards. - * - * Structure: - * 1. The grammar draws a skeleton and builds a legal program from it. - * 2. The model predicts the reports from the program, without the rule. - * 3. The drivers render the program and lint it on each receiving path. - * 4. The comparison checks that each path's reports equal the prediction. - * 5. Campaigns run the comparison with fixed and random seeds, and check - * order invariance, rerun equality, and companion edits between lints. - */ -import { spawnSync } from 'node:child_process' -import { - mkdirSync, - mkdtempSync, - rmSync, - utimesSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import tsParser from '@typescript-eslint/parser' -import { ESLint, Linter } from 'eslint' -import fc from 'fast-check' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { - oraclePropertyOptions, - oracleRuns, -} from '../../packages/db/tests/oracle-config.js' -import { createOracleReach } from '../../packages/db/tests/oracle-reach.js' -import { declareOracle } from '../../packages/db/tests/oracle-declaration.js' -import oraclePlugin from './index.js' - -declareOracle({ - check: [compareReports], - model: [predictReports], - grammar: [programArbitrary], -}) - -const RULE = `oracle/no-unit-test` -const ORACLE = `case-oracle.test.ts` -const PROPERTY = `lint.part-reach` -const FIXED_SEED = 2234 -const BASE_RUNS = 30 -const repoRoot = fileURLToPath(new URL(`../../`, import.meta.url)) - -// -- 1. Grammar --------------------------------------------------------------- -// -// A skeleton is a tree of fast-check values. Each reference index is taken -// modulo the number of names that exist, so every skeleton builds a legal -// program. A reference that has no target is dropped. Feature gates turn a -// family of forms on, so each campaign studies one dimension at a time. - -const SLOTS = 3 -const REFS = 3 - -// Local references are the most common, because mutual recursion among the -// oracle's functions is the shape that the reach rule must get right. -const refSkeleton = fc.record({ - kind: fc.oneof( - { weight: 4, arbitrary: fc.constant(`local`) }, - { weight: 1, arbitrary: fc.constant(`import`) }, - { weight: 1, arbitrary: fc.constant(`type`) }, - ), - index: fc.nat({ max: 9 }), -}) - -const slotSkeleton = fc.record({ - present: fc.boolean(), - exported: fc.boolean(), - part: fc.constantFrom(`none`, `none`, `check`, `model`, `grammar`), - refs: fc.array(refSkeleton, { maxLength: REFS }), -}) - -// A named import is the common form. A namespace import reads the same export -// through a member, and a default import reads the target's default export. -const importForm = fc.oneof( - { weight: 3, arbitrary: fc.constant(`named`) }, - { weight: 1, arbitrary: fc.constant(`namespace`) }, - { weight: 1, arbitrary: fc.constant(`default`) }, -) - -const linkSkeleton = fc.record({ - target: fc.nat({ max: 9 }), - slot: fc.nat({ max: SLOTS - 1 }), - form: importForm, -}) - -// A re-export imports one name and exports it under another, drawn from the -// same pool. That lets two modules re-export each other's names: a cycle. -const reexportSkeleton = fc.record({ - target: fc.nat({ max: 9 }), - slot: fc.nat({ max: SLOTS - 1 }), - exportIndex: fc.nat({ max: 99 }), -}) - -const moduleSkeleton = fc.record({ - slots: fc.tuple(slotSkeleton, slotSkeleton, slotSkeleton), - stars: fc.array(fc.nat({ max: 9 }), { maxLength: 2 }), - reexports: fc.array(reexportSkeleton, { maxLength: 2 }), - imports: fc.array(linkSkeleton, { maxLength: 3 }), - typeAliases: fc.tuple(fc.boolean(), fc.boolean(), fc.boolean()), - typeNames: fc.array(fc.nat({ max: 3 }), { maxLength: 2 }), - missingPart: fc.boolean(), - stringKeys: fc.boolean(), - // The slot that the module exports as its default, if any. - defaultSlot: fc.option(fc.nat({ max: SLOTS - 1 }), { nil: undefined }), - // Whether the declaration takes its first part list from a spread object. - spread: fc.boolean(), - // Whether a companion is a JavaScript module, which holds no type syntax. - js: fc.boolean(), -}) - -const FORMS = [ - `plain`, - `identifier`, - `call`, - `member`, - `options-first`, - `options-last`, - `each`, - `for`, - `helper-plain`, - `helper-prop`, - `property`, - `prop-named`, - `let-driver`, - `for-of`, -] - -// How a caller-passed-body helper registers the body: as its argument, with a -// trailing timeout after it, or wrapped in a function of the helper's own, -// which may await the body or use a declared part of a companion. -const WRAPS = [`direct`, `timed`, `wrap`, `wrap-async`, `wrap-part`] - -// The form index is taken modulo the forms that the feature set enables, so -// each enabled form gets an equal share of the registrations. -const registrationSkeleton = fc.record({ - formIndex: fc.nat({ max: FORMS.length - 1 }), - constantArb: fc.boolean(), - refs: fc.array(refSkeleton, { maxLength: REFS }), - callee: fc.nat({ max: 9 }), - literalOptions: fc.boolean(), - wrapped: fc.boolean(), - rows: fc.array( - fc.oneof( - fc.record({ fn: fc.array(refSkeleton, { maxLength: REFS }) }), - fc.record({ value: fc.constant(1) }), - ), - { minLength: 1, maxLength: 3 }, - ), - // A caller-passed-body helper's layout, and what follows a timed body. - wrap: fc.nat({ max: WRAPS.length - 1 }), - trailing: fc.constantFrom(`literal`, `name`), - // A generated property's arbitrary is written inline or bound to a name; - // its predicate is inline, a local function, or imported, and reads its - // input or not. - arbNamed: fc.boolean(), - predicate: fc.constantFrom(`inline`, `local`, `imported`), - readsInput: fc.boolean(), - // The functions that a `for...of` driver runs, one test each. - elements: fc.array(fc.nat({ max: 9 }), { minLength: 1, maxLength: 2 }), -}) - -const programSkeleton = fc.record({ - companions: fc.array(moduleSkeleton, { maxLength: 3 }), - broken: fc.boolean(), - // An ordinary module, an oracle-named module that is linted on its own, and - // an `oracle-` machinery module, which the rule does not lint. - registrarName: fc.constantFrom(`reg.ts`, `reg-oracle.ts`, `oracle-reg.ts`), - registrarImport: importForm, - helperName: fc.constantFrom( - `reg-helper.ts`, - `reg-helper-oracle.ts`, - `oracle-reg-helper.ts`, - ), - helperImport: fc.constantFrom(`named`, `namespace`), - oracle: fc.record({ - module: moduleSkeleton, - objectRefs: fc.array(refSkeleton, { maxLength: REFS }), - registrarCall: fc.boolean(), - tests: fc.array(registrationSkeleton, { maxLength: 6 }), - }), -}) - -/** The grammar's feature gates. A campaign turns on one family at a time. */ -const BASE = { - companions: true, - forms: [`plain`, `identifier`, `each`], - stars: false, - broken: false, - registrar: false, - stringKeys: false, - typeModels: false, - // Off: every import is named, and every helper is a direct `regPlain` in an - // ordinary module, imported by name. - importForms: false, - helperLayouts: false, - helperNames: false, - spreads: false, - jsCompanions: false, -} -const ALL = { - companions: true, - forms: FORMS, - stars: true, - broken: true, - registrar: true, - stringKeys: true, - typeModels: true, - importForms: true, - helperLayouts: true, - helperNames: true, - spreads: true, - jsCompanions: true, -} - -/** A module that the lint selection lints as an oracle file. */ -const lintedOnItsOwn = (file) => - file.includes(`oracle`) && !file.startsWith(`oracle-`) - -function programArbitrary(features) { - return programSkeleton.map((skeleton) => buildProgram(skeleton, features)) -} - -// -- Program: the builder ---------------------------------------------------- - -const stemOf = (file) => file.replace(/\.(ts|js)$/, ``) -const specOf = (file) => `./${stemOf(file)}.js` -const nodeKey = (file, name) => `${file}\0${name}` - -/** - * A program, as the model and the drivers read it: - * modules: Map file -> module (see buildModule) - * oracle: the file that the rule lints - * tests: the oracle's registrations, in source order - * calls: the calls that the oracle makes to imported helpers - */ -function buildProgram(skeleton, features) { - // A single-file program has no companion, so its recursion is only the - // oracle's own functions. - const companions = features.companions ? skeleton.companions : [] - const companionFiles = companions.map( - (companion, index) => - `c${index}.${features.jsCompanions && companion.js ? `js` : `ts`}`, - ) - const brokenFile = - features.broken && skeleton.broken ? `broken.ts` : undefined - const registrarFile = - features.registrar && skeleton.oracle.registrarCall - ? skeleton.registrarName - : undefined - const modules = new Map() - // An acyclic program lets a companion import only from earlier companions, so - // no export cycle can form. - const targetsOf = (self) => [ - ...companionFiles.filter((file) => - features.acyclic ? file < self : file !== self, - ), - ...(brokenFile ? [brokenFile] : []), - ] - const oracleTargets = [...companionFiles, ...(brokenFile ? [brokenFile] : [])] - // The names that a re-export may export under: every companion slot. - const pool = companionFiles.flatMap((file) => - [0, 1, 2].map((index) => slotName(file, index)), - ) - - companions.forEach((companion, index) => { - const file = companionFiles[index] - modules.set( - file, - buildModule({ - file, - role: `companion`, - skeleton: companion, - targets: targetsOf(file), - pool, - features, - extras: [], - }), - ) - }) - if (brokenFile !== undefined) { - modules.set(brokenFile, { - file: brokenFile, - role: `broken`, - bindings: new Map(), - exportEntries: [], - stars: [], - imports: new Map(), - types: new Set(), - typeNames: [], - parts: new Map(), - missing: [], - declares: false, - stringKeys: false, - }) - } - const emptyModule = (file, role, fields = {}) => ({ - file, - role, - bindings: new Map(), - exportEntries: [], - stars: [], - imports: new Map(), - types: new Set(), - typeNames: [], - parts: new Map(), - missing: [], - declares: false, - stringKeys: false, - spread: false, - ...fields, - }) - if (registrarFile !== undefined) { - modules.set( - registrarFile, - emptyModule(registrarFile, `registrar`, { - bindings: new Map([ - [`regAll`, { kind: `fn`, refs: [], exported: true }], - ]), - exportEntries: [ - { name: `regAll`, local: `regAll` }, - { name: `default`, local: `regAll` }, - ], - }), - ) - } - const registrarImport = features.importForms - ? skeleton.registrarImport - : `named` - const oracleSkeleton = skeleton.oracle - const oracle = buildModule({ - file: ORACLE, - role: `oracle`, - skeleton: oracleSkeleton.module, - targets: oracleTargets, - pool, - features, - // The helpers that registrations use: `oobj` holds a method body, `oopt` - // is a non-function options value, and `owrap` is a callee that reaches - // nothing. Their references resolve like any other binding's. - extras: [ - { name: `oobj`, kind: `obj`, refs: oracleSkeleton.objectRefs }, - { name: `oopt`, kind: `opt`, refs: [] }, - { name: `owrap`, kind: `fn`, refs: [] }, - ], - registrarFile, - registrarImport, - }) - modules.set(ORACLE, oracle) - const helperFile = features.helperNames - ? skeleton.helperName - : `reg-helper.ts` - const context = { - oracle, - features, - companionFiles, - helperFile, - } - const tests = oracleSkeleton.tests.map((test, index) => - buildRegistration(test, index, context), - ) - const isRegistrarAlias = (alias) => - registrarFile !== undefined && - oracle.imports.get(alias)?.from === registrarFile - const calls = [] - if (registrarFile !== undefined) { - calls.push({ alias: `regAll`, file: registrarFile }) - } - // An imported helper that registers the body its caller passes. The oracle - // file imports it by name or through a namespace. - if (tests.some((test) => test.helper !== undefined)) { - const form = features.importForms ? skeleton.helperImport : `named` - for (const name of HELPER_EXPORTS) { - oracle.imports.set(name, { from: helperFile, imported: name, form }) - } - // `regWrapPart`'s wrapper uses the first companion's first function. - const helperImports = new Map() - if (companionFiles.length > 0) { - helperImports.set(`hpart`, { - from: companionFiles[0], - imported: slotName(companionFiles[0], 0), - form: `named`, - }) - } - modules.set( - helperFile, - emptyModule(helperFile, `helper`, { - imports: helperImports, - bindings: new Map([ - [ - `hpart-wrapper`, - { - kind: `fn`, - refs: helperImports.has(`hpart`) - ? [{ kind: `import`, alias: `hpart` }] - : [], - }, - ], - ]), - }), - ) - } - // The module that exports the imported predicates, which read their input - // or ignore it. - if (tests.some((test) => test.property?.predicate === `imported`)) { - oracle.imports.set(`predReads`, { - from: `pred.ts`, - imported: `predReads`, - form: `named`, - }) - oracle.imports.set(`predIgnores`, { - from: `pred.ts`, - imported: `predIgnores`, - form: `named`, - }) - modules.set(`pred.ts`, emptyModule(`pred.ts`, `pred`)) - } - for (const test of tests) { - const { body } = test - if ( - body.kind === `call` && - body.callee.kind === `import` && - isRegistrarAlias(body.callee.alias) - ) { - calls.push({ - alias: body.callee.alias, - file: registrarFile, - test: test.title, - }) - } - } - return { oracle: ORACLE, modules, tests, calls, helperFile } -} - -// The helper module's exports, in the order HELPER_TEXT defines them. -const HELPER_EXPORTS = [ - `regPlain`, - `regTimed`, - `regWrap`, - `regWrapAsync`, - `regWrapPart`, - `regProp`, -] -const HELPER_OF_WRAP = { - direct: `regPlain`, - timed: `regTimed`, - wrap: `regWrap`, - 'wrap-async': `regWrapAsync`, - 'wrap-part': `regWrapPart`, -} - -/** The prefix of a module's own function names: `ofn` in an oracle, `c0f` in c0. */ -function prefixOf(file, role) { - return role === `oracle` ? `ofn` : `${stemOf(file)}f` -} - -/** The name that slot `index` of `file` exports, or the broken module's name. */ -function slotName(file, index) { - return file.startsWith(`broken`) ? `brokenFn` : `${stemOf(file)}f${index}` -} - -function buildModule({ - file, - role, - skeleton, - targets, - pool, - features, - extras, - registrarFile, - registrarImport = `named`, -}) { - const prefix = prefixOf(file, role) - const pick = (index) => - targets.length === 0 ? undefined : targets[index % targets.length] - const bindings = new Map() - const exportEntries = [] - const imports = new Map() - skeleton.slots.forEach((slot, index) => { - if (!slot.present) return - const name = `${prefix}${index}` - bindings.set(name, { - kind: `fn`, - refs: [], - exported: slot.exported, - skeleton: slot.refs, - part: slot.part, - }) - if (slot.exported) exportEntries.push({ name, local: name }) - }) - // A default export names one of the module's own functions. - if ( - features.importForms && - skeleton.defaultSlot !== undefined && - bindings.has(`${prefix}${skeleton.defaultSlot}`) - ) { - exportEntries.push({ - name: `default`, - local: `${prefix}${skeleton.defaultSlot}`, - }) - } - for (const extra of extras) { - bindings.set(extra.name, { - kind: extra.kind, - refs: [], - exported: false, - skeleton: extra.refs, - part: `none`, - }) - } - if (registrarFile !== undefined) { - // A default import reads the registrar's default export, which is regAll. - imports.set(`regAll`, { - from: registrarFile, - imported: registrarImport === `default` ? `default` : `regAll`, - form: registrarImport, - }) - } - const stars = features.stars - ? skeleton.stars.map(pick).filter((from) => from !== undefined) - : [] - for (const link of skeleton.reexports) { - const from = - targets.length === 0 ? undefined : targets[link.target % targets.length] - if (from === undefined || pool.length === 0) continue - const name = pool[link.exportIndex % pool.length] - // An export name is unique within a module, so a re-export that repeats - // one is dropped, as a duplicate export is a syntax error. - if (exportEntries.some((entry) => entry.name === name)) continue - exportEntries.push({ name, from, imported: slotName(from, link.slot) }) - } - skeleton.imports.forEach((link, index) => { - const from = - targets.length === 0 ? undefined : targets[link.target % targets.length] - if (from === undefined) return - const form = features.importForms ? link.form : `named` - imports.set(`i${index}`, { - from, - imported: form === `default` ? `default` : slotName(from, link.slot), - form, - }) - }) - - // References resolve only after every name exists, so a helper such as - // `owrap` is a legal target from the oracle's own functions. - // Local references name the module's functions. Helpers such as `oobj` - // are reached through registration forms, so they do not dilute recursion. - const helpers = new Set(extras.map((extra) => extra.name)) - const names = [...bindings.keys()].filter((name) => !helpers.has(name)) - const aliases = [...imports.keys()] - const resolve = (ref) => resolveRef(ref, names, aliases) - const javascript = file.endsWith(`.js`) - for (const binding of bindings.values()) { - binding.refs = binding.skeleton - .map(resolve) - .filter( - (ref) => ref !== undefined && !(javascript && ref.kind === `type`), - ) - delete binding.skeleton - } - - const types = new Set( - [`T0`, `T1`, `T2`].filter( - (_, index) => !javascript && skeleton.typeAliases[index], - ), - ) - const typeNames = - !javascript && features.typeModels - ? [ - ...new Set( - skeleton.typeNames.map((index) => - index === 3 ? `TX` : `T${index}`, - ), - ), - ] - : [] - const parts = new Map() - for (const [name, binding] of bindings) { - if (binding.part !== undefined && binding.part !== `none`) { - parts.set(name, binding.part) - } - } - const missing = skeleton.missingPart ? [`ghostPart`] : [] - return { - file, - role, - bindings, - exportEntries, - stars, - imports, - types, - typeNames, - parts, - missing, - declares: parts.size > 0 || typeNames.length > 0 || missing.length > 0, - stringKeys: features.stringKeys && skeleton.stringKeys, - spread: features.spreads && skeleton.spread, - } -} - -function resolveRef(ref, names, aliases) { - if (ref.kind === `local`) { - return names.length === 0 - ? undefined - : { kind: `local`, name: names[ref.index % names.length] } - } - if (ref.kind === `import`) { - return aliases.length === 0 - ? undefined - : { kind: `import`, alias: aliases[ref.index % aliases.length] } - } - return { kind: `type`, name: `T${ref.index % 3}` } -} - -/** - * One registration in the oracle file. A form outside the active feature set - * falls back to a plain registration, so a gated form never appears by - * accident. - */ -function buildRegistration(skeleton, index, context) { - const { oracle, features, companionFiles } = context - const title = `t${index}` - const names = [...oracle.bindings.keys()] - const aliases = [...oracle.imports.keys()] - const resolve = (ref) => resolveRef(ref, names, aliases) - const functionNames = names.filter( - (name) => name.startsWith(`ofn`) && oracle.bindings.get(name).kind === `fn`, - ) - // A fixed input names its form. A generated skeleton takes the form at its index. - const requested = - skeleton.form ?? features.forms[skeleton.formIndex % features.forms.length] - let form = features.forms.includes(requested) ? requested : `plain` - if ( - (form === `identifier` || form === `for-of`) && - functionNames.length === 0 - ) { - form = `plain` - } - const refs = skeleton.refs.map(resolve).filter((ref) => ref !== undefined) - const test = { title, form, wrapped: skeleton.wrapped, refs } - const pickFunction = (value) => functionNames[value % functionNames.length] - if (form === `helper-plain` || form === `helper-prop`) { - // A layout that needs a companion falls back to a plain wrapper. - let wrap = features.helperLayouts - ? WRAPS[(skeleton.wrap ?? 0) % WRAPS.length] - : `direct` - if (wrap === `wrap-part` && companionFiles.length === 0) wrap = `wrap` - const trailing = - skeleton.trailing === `name` && functionNames.length > 0 - ? { kind: `name`, name: pickFunction(skeleton.callee) } - : { kind: `literal` } - test.helper = { - prop: form === `helper-prop`, - constant: skeleton.constantArb, - arbNamed: form === `helper-prop` && (skeleton.arbNamed ?? false), - // A property helper registers its body directly, as serializerProp does. - wrap: form === `helper-prop` ? `direct` : wrap, - trailing, - // A plain helper's body ignores its input unless the test is a property. - readsInput: form === `helper-prop` ? (skeleton.readsInput ?? true) : true, - } - test.body = { kind: `arrow`, refs } - } else if (form === `property` || form === `prop-named`) { - test.property = { - constant: skeleton.constantArb, - arbNamed: skeleton.arbNamed ?? false, - predicate: skeleton.predicate ?? `inline`, - readsInput: skeleton.readsInput ?? true, - } - test.body = { kind: `arrow`, refs: [] } - } else if (form === `let-driver`) { - test.driver = { kind: `let`, refs } - test.body = { kind: `arrow`, refs: [] } - } else if (form === `for-of`) { - test.driver = { - kind: `for-of`, - elements: (skeleton.elements ?? [0]).map(pickFunction), - } - test.body = { kind: `arrow`, refs: [] } - } else if (form === `identifier`) { - test.body = { - kind: `ident`, - name: functionNames[skeleton.callee % functionNames.length], - } - } else if (form === `call`) { - const callee = - aliases.length > 0 && skeleton.callee % 2 === 1 - ? { kind: `import`, alias: aliases[skeleton.callee % aliases.length] } - : { kind: `local`, name: `owrap` } - test.body = { kind: `call`, callee, refs } - } else if (form === `member`) { - test.body = { kind: `member`, object: `oobj` } - } else if (form === `options-first` || form === `options-last`) { - test.body = { kind: `arrow`, refs } - test.options = { - at: form === `options-first` ? `first` : `last`, - literal: skeleton.literalOptions, - } - } else if (form === `each` || form === `for`) { - test.table = { - kind: form, - rows: skeleton.rows.map((row) => - row.fn === undefined - ? { value: 1 } - : { fn: row.fn.map(resolve).filter((ref) => ref !== undefined) }, - ), - } - test.body = { kind: `arrow`, refs: [] } - } else { - test.body = { kind: `arrow`, refs } - } - return test -} - -// -- 2. Model ----------------------------------------------------------------- -// -// The model predicts the reports from the program's structure, without the -// rule's code. It reads the program as data: each binding's references, each -// module's exports and stars, and each registration's body and table. A -// binding reaches a part when it is declared, or when one of its references -// reaches one; the reach relation is the least fixed point of that equation, -// found by iterating until no binding changes. A name that a module exports -// resolves through its named re-exports and its star exports, and a visited -// set ends a cycle of exports. A type reference reaches a part only when its -// name is a declared type model with an alias in the same file. -// -// A namespace import names an export through a member, so it resolves like a -// named import. A default import resolves the target's own `default` entry: -// `export *` does not carry a default (ECMAScript), so stars are not followed. -// -// A registration's test reaches a part in one of these ways: -// - Its body, or a function in its `.each` or `.for` table, uses one. -// - It registers through a helper that passes on its caller's body. The -// test is the argument at the body's position, so a trailing timeout or a -// trailing name says nothing. A wrapper that the helper writes around the -// body reaches when the wrapper uses a part itself. -// - It runs a generated property: some arbitrary is not `fc.constant`, under -// any name, and the predicate reads its input, wherever it is defined. -// - It runs a `let` driver, which reaches when the value assigned to it does. -// - It runs inside a `for...of` loop, which registers one test per element, -// so every element must reach (rubric: every test uses a declared part). -// -// The predicted report list follows the rule's contract. Each unreached -// registration is a unit test. A file with no declared part and no reaching -// registration gets one `undeclared` report, on its first unreached test. Each -// declared type-model name without an alias is reported. Each call to a helper -// that registers its own tests is reported, unless the helper's module is -// linted on its own: an oracle-named module, but not an `oracle-` machinery -// module, which no lint reads (rubric "Name oracle files consistently"). A -// helper that passes on its caller's body from a module linted on its own is -// judged there, so its calls are not judged in the oracle file. - -/** The reports that the rule should give for a program, as sorted keys. */ -function predictReports(program) { - const oracle = program.modules.get(program.oracle) - const importNodes = new Map() - // An explicit export of a name shadows the star exports, as in ES. Star - // exports are followed only when the module has no explicit export of it. - const exportNodes = (file, name, seen = new Set()) => { - if (seen.has(nodeKey(file, name))) return [] - seen.add(nodeKey(file, name)) - const module = program.modules.get(file) - if (module === undefined) return [] - const entry = module.exportEntries.find( - (candidate) => candidate.name === name, - ) - if (entry !== undefined) { - if (entry.local === undefined) { - return exportNodes(entry.from, entry.imported, seen) - } - return module.bindings.has(entry.local) - ? [nodeKey(file, entry.local)] - : [] - } - if (name === `default`) return [] - return module.stars.flatMap((star) => exportNodes(star, name, seen)) - } - for (const [file, module] of program.modules) { - for (const [alias, link] of module.imports) { - importNodes.set( - nodeKey(file, alias), - exportNodes(link.from, link.imported), - ) - } - } - - const reached = new Set() - for (const [file, module] of program.modules) { - for (const name of module.parts.keys()) reached.add(nodeKey(file, name)) - } - const isModel = (module, name) => - module.typeNames.includes(name) && module.types.has(name) - const refReaches = (file, module, ref) => { - if (ref.kind === `local`) return reached.has(nodeKey(file, ref.name)) - if (ref.kind === `import`) { - return (importNodes.get(nodeKey(file, ref.alias)) ?? []).some((node) => - reached.has(node), - ) - } - return isModel(module, ref.name) - } - for (let changed = true; changed;) { - changed = false - for (const [file, module] of program.modules) { - for (const [name, binding] of module.bindings) { - const id = nodeKey(file, name) - if (reached.has(id)) continue - if (binding.refs.some((ref) => refReaches(file, module, ref))) { - reached.add(id) - changed = true - } - } - } - } - - const refOfOracle = (ref) => refReaches(program.oracle, oracle, ref) - const functionReaches = (name) => reached.has(nodeKey(program.oracle, name)) - const reachesPart = (test) => { - const { helper, property, driver } = test - if (helper !== undefined) { - // A generated property: a non-constant arbitrary, and a body that reads its value. - if (helper.prop && !helper.constant && helper.readsInput) return true - const wrapperReaches = - helper.wrap === `wrap-part` && - reached.has(nodeKey(program.helperFile, `hpart-wrapper`)) - return wrapperReaches || test.body.refs.some(refOfOracle) - } - if (property !== undefined) return !property.constant && property.readsInput - if (driver?.kind === `let`) return driver.refs.some(refOfOracle) - if (driver?.kind === `for-of`) return driver.elements.every(functionReaches) - const { body } = test - const bodyReaches = - body.kind === `arrow` - ? body.refs.some(refOfOracle) - : body.kind === `ident` - ? reached.has(nodeKey(program.oracle, body.name)) - : body.kind === `call` - ? refOfOracle(body.callee) || body.refs.some(refOfOracle) - : reached.has(nodeKey(program.oracle, body.object)) - const tableReaches = - test.table !== undefined && - test.table.rows.some( - (row) => row.fn !== undefined && row.fn.some(refOfOracle), - ) - return bodyReaches || tableReaches - } - const judged = program.tests.filter( - (test) => test.helper === undefined || !lintedOnItsOwn(program.helperFile), - ) - const unreached = judged - .filter((test) => !reachesPart(test)) - .map((test) => test.title) - const anyReached = unreached.length < judged.length - const declaresParts = oracle.parts.size > 0 || oracle.typeNames.length > 0 - - const keys = [] - if (!declaresParts && !anyReached) { - if (unreached.length > 0) keys.push(`undeclared:${unreached[0]}`) - } else { - for (const title of unreached) keys.push(`unit:${title}`) - } - for (const name of oracle.typeNames) { - if (!oracle.types.has(name)) keys.push(`typeModel:${name}`) - } - for (const call of program.calls) { - if (!lintedOnItsOwn(call.file)) keys.push(`imported:${call.alias}`) - } - return keys.sort() -} - -// -- 3. Drivers --------------------------------------------------------------- -// -// A driver renders a program to files, lints the oracle file, and turns each -// report into a key. A key names its subject (the test title, the type-model -// name, or the helper), so the comparison does not depend on line numbers. -// The renderer records the line of each subject, and a report's line finds it. - -const REGISTRAR_TEXT = `import { it } from 'vitest'\nexport function regAll() { it('reg inner', () => { void 0 }) }\nexport default regAll\n` -const BROKEN_TEXT = `export function brokenFn() {\n` -const PRED_TEXT = `export function predReads(value) {\n void value\n}\nexport function predIgnores(value) {\n void 0\n}\n` - -/** - * Helpers that register the body their caller passes: directly, before a - * timeout, wrapped in a function, awaited in an async wrapper, beside a use of - * a companion's function, and as a curried fast-check property, as - * serializerProp does. Their order is HELPER_EXPORTS. - */ -function helperText(module) { - const lines = [ - `import { it } from 'vitest'`, - `import { test as fcTest } from '@fast-check/vitest'`, - ] - for (const [alias, link] of module.imports) { - lines.push( - `import { ${link.imported} as ${alias} } from '${specOf(link.from)}'`, - ) - } - lines.push( - `export function regPlain(name, body) { it(name, body) }`, - `export function regTimed(name, body, timeout) { it(name, body, timeout) }`, - `export function regWrap(name, body) { it(name, () => { body() }) }`, - `export function regWrapAsync(name, body) { it(name, async () => { await body() }) }`, - `export function regWrapPart(name, body) { it(name, () => { void hpart; body() }) }`, - `export function regProp(arbitraries) {`, - ` return (name, check) => { fcTest.prop(arbitraries)(name, check) }`, - `}`, - ) - return `${lines.join(`\n`)}\n` -} - -/** The text that uses an imported alias: a namespace import reads a member. */ -function useText(module, alias) { - const link = module.imports.get(alias) - return link?.form === `namespace` ? `${alias}.${link.imported}` : alias -} - -function importText(alias, link) { - const from = specOf(link.from) - if (link.form === `namespace`) return `import * as ${alias} from '${from}'` - if (link.form === `default`) return `import ${alias} from '${from}'` - return `import { ${link.imported} as ${alias} } from '${from}'` -} - -/** The files for a program, and the subject that each line of the oracle names. */ -function renderProgram(program) { - const files = new Map() - let subjects = new Map() - for (const [file, module] of program.modules) { - if (module.role === `broken`) { - files.set(file, BROKEN_TEXT) - } else if (module.role === `registrar`) { - files.set(file, REGISTRAR_TEXT) - } else if (module.role === `helper`) { - files.set(file, helperText(module)) - } else if (module.role === `pred`) { - files.set(file, PRED_TEXT) - } else { - const rendered = renderModule(module, program) - files.set(file, rendered.text) - if (file === program.oracle) subjects = rendered.subjects - } - } - return { files, subjects } -} - -function renderModule(module, program) { - const lines = [] - const subjects = new Map() - // A text may span several lines, so a subject names the line where its text starts. - const push = (text, subject) => { - const start = lines.length + 1 - lines.push(...text.split(`\n`)) - if (subject !== undefined) subjects.set(start, subject) - } - const use = (alias) => useText(module, alias) - if (module.declares) { - push(`import { declareOracle } from './oracle-declaration.js'`) - } - if (module.role === `oracle`) { - if ( - program.tests.some( - (test) => test.property !== undefined || test.helper?.prop, - ) - ) { - push(`import fc from 'fast-check'`) - } - if (program.tests.some((test) => test.form === `prop-named`)) { - push(`import { test as fcTest } from '@fast-check/vitest'`) - } - } - for (const [alias, link] of module.imports) push(importText(alias, link)) - for (const name of module.types) push(`type ${name} = X`) - for (const [name, binding] of module.bindings) { - if (binding.kind === `obj`) { - push(`const ${name} = {`) - push(` m: () => {`) - for (const statement of statementsOf(binding.refs, use)) - push(` ${statement}`) - push(` },`) - push(`}`) - } else if (binding.kind === `opt`) { - push(`const ${name} = { timeout: 1000 }`) - } else { - const exported = module.exportEntries.some( - (entry) => entry.local === name && entry.name !== `default`, - ) - push(`${exported ? `export ` : ``}function ${name}() {`) - for (const statement of statementsOf(binding.refs, use)) - push(` ${statement}`) - push(`}`) - } - } - for (const entry of module.exportEntries) { - if (entry.from !== undefined) { - push( - `export { ${entry.imported} as ${entry.name} } from '${specOf(entry.from)}'`, - ) - } else if (entry.name === `default`) { - push(`export default ${entry.local}`) - } - } - for (const star of module.stars) push(`export * from '${specOf(star)}'`) - if (module.role === `oracle`) { - if (module.declares) renderDeclaration(module, push) - // A call inside a registration is rendered with that registration. - for (const call of program.calls.filter( - (entry) => entry.test === undefined, - )) { - push(`${use(call.alias)}()`, { id: call.alias }) - } - for (const test of program.tests) { - renderTest(test, push, use) - } - } else if (module.declares) { - renderDeclaration(module, push) - } - return { text: `${lines.join(`\n`)}\n`, subjects } -} - -/** - * Each declared part is a list; each type-model name gets its own line. A - * module with `spread` writes its first part list in a const object that the - * declaration spreads. - */ -function renderDeclaration(module, push) { - const partKey = (part) => (module.stringKeys ? `'${part}'` : part) - const lists = [] - for (const part of [`check`, `model`, `grammar`]) { - const names = [...module.parts] - .filter(([, kind]) => kind === part) - .map(([name]) => name) - if (part === `check`) names.push(...module.missing) - if (names.length > 0) lists.push(`${partKey(part)}: [${names.join(`, `)}]`) - } - const spread = module.spread && lists.length > 0 ? lists.shift() : undefined - if (spread !== undefined) push(`const sharedParts = { ${spread} }`) - push(`declareOracle({`) - if (spread !== undefined) push(` ...sharedParts,`) - for (const list of lists) push(` ${list},`) - if (module.typeNames.length > 0) { - push(` ${partKey(`typeModel`)}: [`) - for (const name of module.typeNames) { - push(` '${name}',`, { id: name }) - } - push(` ],`) - } - push(`})`) -} - -function statementsOf(refs, use = (alias) => alias) { - return refs.map((ref, index) => { - if (ref.kind === `type`) return `type _t${index} = ${ref.name}` - return `void ${ref.kind === `local` ? ref.name : use(ref.alias)}` - }) -} - -function arrowText(refs, use) { - return `() => {\n expect(1).toBe(1)\n ${statementsOf(refs, use).join(`\n `)}\n }` -} - -/** Renders one registration. The first line carries its subject. */ -// The helper that a registration calls, which names an importedRegistration report. -function callHelper(test) { - return test.body.kind === `call` && test.body.callee.kind === `import` - ? test.body.callee.alias - : undefined -} - -function renderTest(test, push, use) { - // The bindings that one registration uses are written just before it. - const arbitrary = (constant) => (constant ? `fc.constant(1)` : `fc.integer()`) - const named = test.property ?? (test.helper?.prop ? test.helper : undefined) - if (named?.arbNamed) { - push(`const arb${test.title} = ${arbitrary(named.constant)}`) - } - if (test.property?.predicate === `local`) { - push(`function pred${test.title}(value) {`) - push(` expect(1).toBe(1)`) - push(test.property.readsInput ? ` void value` : ` void 0`) - push(`}`) - } - if (test.driver?.kind === `let`) { - push(`let drv${test.title}`) - push(`beforeAll(() => {`) - push(` drv${test.title} = {`) - push(` run: () => {`) - for (const statement of statementsOf(test.driver.refs, use)) - push(` ${statement}`) - push(` },`) - push(` }`) - push(`})`) - } - if (test.wrapped) push(`describe('d${test.title}', () => {`) - const text = registrationText(test, use) - text.forEach((line, offset) => { - push( - line, - offset === 0 ? { id: test.title, helper: callHelper(test) } : undefined, - ) - }) - if (test.wrapped) push(`})`) -} - -function registrationText(test, use) { - const { body, title } = test - if (test.helper !== undefined) { - const { helper } = test - const arbitrary = helper.arbNamed - ? `arb${title}` - : helper.constant - ? `fc.constant(1)` - : `fc.integer()` - const head = helper.prop - ? `${use(`regProp`)}([${arbitrary}])` - : use(HELPER_OF_WRAP[helper.wrap]) - const trailing = - helper.wrap !== `timed` - ? `` - : helper.trailing.kind === `name` - ? `, ${helper.trailing.name}` - : `, 1000` - return [ - `${head}('${title}', (value) => {`, - ` expect(1).toBe(1)`, - helper.readsInput ? ` void value` : ` void 0`, - ` ${statementsOf(body.refs, use).join(`\n `)}`, - ` }${trailing})`, - ] - } - if (test.property !== undefined) { - const { property } = test - const arbitrary = property.arbNamed - ? `arb${title}` - : property.constant - ? `fc.constant(1)` - : `fc.integer()` - const predicate = - property.predicate === `local` - ? `pred${title}` - : property.predicate === `imported` - ? property.readsInput - ? `predReads` - : `predIgnores` - : `(value) => {\n expect(1).toBe(1)\n ${property.readsInput ? `void value` : `void 0`}\n }` - if (test.form === `prop-named`) { - return [`fcTest.prop([${arbitrary}])('${title}', ${predicate})`] - } - return [ - `it('${title}', () => {`, - ` fc.assert(fc.property(${arbitrary}, ${predicate}))`, - ` })`, - ] - } - if (test.driver?.kind === `let`) { - return [`it('${title}', () => {`, ` drv${title}.run()`, ` })`] - } - if (test.driver?.kind === `for-of`) { - // The loop and its registration share a line, so the report's line - // names this test. - return [ - `for (const drive${title} of [${test.driver.elements.join(`, `)}]) it('${title}', () => {`, - ` drive${title}()`, - ` })`, - ] - } - if (test.table !== undefined) { - const rows = test.table.rows.map((row) => - row.fn === undefined - ? `[1]` - : `[() => {\n ${statementsOf(row.fn, use).join(`\n `)}\n }]`, - ) - const runner = test.table.kind === `each` ? `it.each` : `it.for` - return [ - `${runner}([${rows.join(`, `)}])('${title} %s', () => {`, - ` expect(1).toBe(1)`, - ` })`, - ] - } - if (body.kind === `ident`) return [`it('${title}', ${body.name})`] - if (body.kind === `member`) return [`it('${title}', ${body.object}.m)`] - if (body.kind === `call`) { - const callee = - body.callee.kind === `local` ? body.callee.name : use(body.callee.alias) - return [`it('${title}', ${callee}(${arrowText(body.refs, use)}))`] - } - if (test.options !== undefined) { - const options = test.options.literal ? `{ timeout: 5000 }` : `oopt` - return test.options.at === `first` - ? [`it('${title}', ${options}, ${arrowText(body.refs, use)})`] - : [`it('${title}', ${arrowText(body.refs, use)}, ${options})`] - } - return [`it('${title}', ${arrowText(body.refs, use)})`] -} - -/** - * The keys of a message list. Every message that is not a report of this rule - * is a key too, so a configuration or parse failure cannot pass as no reports. - */ -function keysOf(messages, subjects) { - return messages.map((message) => keyOf(message, subjects)).sort() -} - -function keyOf(message, subjects) { - if (message.ruleId !== RULE) { - return `other:${message.ruleId ?? `fatal`}@${message.line}:${message.message}` - } - const subject = subjects.get(message.line) - const id = message.messageId - if (subject === undefined) return `unmatched:${id}@${message.line}` - if (id === `unitTest`) return `unit:${subject.id}` - if (id === `undeclared`) return `undeclared:${subject.id}` - if (id === `unknownTypeModel`) return `typeModel:${subject.id}` - if (id === `importedRegistration`) - return `imported:${subject.helper ?? subject.id}` - return `other:${id}@${message.line}` -} - -// The CLI prints messages, not ids, so its driver maps the message text. -const CLI_IDS = [ - [`This test reaches no part`, `unitTest`], - [ - `This oracle file registers tests and declares no oracle part`, - `undeclared`, - ], - [`This typeModel name is not a type alias`, `unknownTypeModel`], - [`This oracle file calls a helper`, `importedRegistration`], -] - -/** - * The keys of the CLI's report lines for the oracle file. The CLI exits 0 with - * no reports, 1 for text reports, and 2 for hook reports; any other status is - * a failure of the CLI itself, not a report. - */ -function cliKeys(result, subjects) { - // A rule that throws makes the CLI print the error, not a report line. - if ( - ![0, 1, 2].includes(result.status) || - result.stderr.includes(`Occurred while linting`) - ) { - throw new Error( - `lint CLI failed with status ${result.status}: ${result.stderr}`, - ) - } - const messages = [] - for (const line of result.stderr.split(`\n`)) { - const match = /^(.+?):(\d+):\d+ (.*) \((\S+)\)$/.exec(line) - if (match === null || !match[1].endsWith(ORACLE)) continue - const [, , lineText, text, ruleId] = match - const id = CLI_IDS.find(([prefix]) => text.startsWith(prefix))?.[1] - messages.push({ - ruleId, - messageId: id ?? `unknown-message`, - message: text, - line: Number(lineText), - }) - } - return keysOf(messages, subjects) -} - -// Without `files`, flat config applies only to the default .js patterns. -const linterConfig = [ - { - files: [`**/*.ts`], - languageOptions: { parser: tsParser, sourceType: `module` }, - plugins: { oracle: oraclePlugin }, - rules: { [RULE]: `error` }, - }, -] - -// -- Receiving paths ------------------------------------------------------------ - -let root -let eslint -let linter -let scratch -let historySeq = 0 - -beforeAll(() => { - root = mkdtempSync(join(tmpdir(), `part-reach-oracle-`)) - writeFileSync(join(root, `package.json`), JSON.stringify({ name: `scratch` })) - // The long-lived path: one ESLint instance, configured as the CLI configures it. - eslint = new ESLint({ - cwd: root, - overrideConfigFile: true, - overrideConfig: [ - { ignores: [`**/dist/**`] }, - { files: [`**/*.ts`, `**/*.tsx`], languageOptions: { parser: tsParser } }, - { linterOptions: { noInlineConfig: true } }, - oraclePlugin.configs.recommended, - ], - }) - linter = new Linter({ configType: `flat` }) - // The CLI reads only files inside its repository. - scratch = mkdtempSync(join(repoRoot, `part-reach-scratch-`)) -}) - -// Every history writes its own directory, so removing them can take a while. -afterAll(() => { - rmSync(root, { recursive: true, force: true }) - rmSync(scratch, { recursive: true, force: true }) -}, 300_000) - -/** Writes a program's files into `directory`; returns the oracle's path, text, and subjects. */ -function writeProgram(directory, program) { - mkdirSync(directory, { recursive: true }) - const { files, subjects } = renderProgram(program) - for (const [file, text] of files) writeFileSync(join(directory, file), text) - return { - oracle: join(directory, program.oracle), - text: files.get(program.oracle), - subjects, - } -} - -/** Path 1: a long-lived ESLint instance, linting a new directory each time. */ -async function lintWithEslint(program) { - const { oracle, subjects } = writeProgram( - join(root, `h${historySeq++}`), - program, - ) - if (process.env.REACH_ORACLE_DEBUG) { - console.log( - `DEBUG ${oracle}\n${[...renderProgram(program).files].map(([f, t]) => `--- ${f}\n${t}`).join(`\n`)}`, - ) - } - const results = await eslint.lintFiles([oracle]) - return keysOf( - results.flatMap((result) => result.messages), - subjects, - ) -} - -/** - * Path 2: one single-file Linter call, as a RuleTester caller makes it. Flat - * config matches a file only inside the working directory, so the program is - * written inside the worktree. - */ -function lintWithLinter(program) { - const { oracle, text, subjects } = writeProgram( - join(scratch, `l${historySeq++}`), - program, - ) - return keysOf( - linter.verify(text, linterConfig, { filename: oracle }), - subjects, - ) -} - -/** Path 3: the lint CLI, a fresh process, on files inside the worktree. */ -function lintWithCli(program) { - const directory = join(scratch, `c${historySeq++}`) - const { oracle, subjects } = writeProgram(directory, program) - const result = spawnSync( - process.execPath, - [ - join(repoRoot, `scripts/agent-lint-feedback.mjs`), - `--format=text`, - oracle, - ], - { cwd: repoRoot, encoding: `utf8` }, - ) - return cliKeys(result, subjects) -} - -/** Path 4: the CLI's edit-hook form, given the changed file as its hook input. */ -function lintWithEditHook(program) { - const directory = join(scratch, `e${historySeq++}`) - const { oracle, subjects } = writeProgram(directory, program) - const result = spawnSync( - process.execPath, - [ - join(repoRoot, `scripts/agent-lint-feedback.mjs`), - `--format=claude`, - `--event=edit`, - ], - { - cwd: repoRoot, - encoding: `utf8`, - input: JSON.stringify({ tool_input: { file_path: oracle } }), - }, - ) - return cliKeys(result, subjects) -} - -// -- 4. Comparison -------------------------------------------------------------- - -/** The paths agree with the model. A mismatch names the path and the keys. */ -function compareReports(path, actual, expected) { - const observed = JSON.stringify(actual) - const predicted = JSON.stringify(expected) - if (observed !== predicted) { - throw new Error(`${path} reports ${observed}, predicted ${predicted}`) - } -} - -// -- 5. Declared reach and campaigns ------------------------------------------- -// -// A state is declared when a law depends on it, and the campaign fails when -// generated programs enter it fewer times than `minimum`. Each state is read -// from the program's structure: the grammar's own witness, not the model. - -const STATES = [ - `export-star-cycle`, - `named-reexport-cycle`, - `mutual-recursion-reaches-part`, - `mutual-recursion-reaches-no-part`, - `form-each`, - `form-for`, - `form-call-body`, - `form-member-body`, - `form-options`, - `string-keyed-declaration`, - `type-model-declaration`, - `unparseable-companion`, - `imported-registration`, - `caller-passed-body-helper`, - `companion-edited-between-lints`, - // Import forms: a namespace member, a default, and a default import of a - // module whose only exports of that name come through `export *`. - `namespace-import`, - `default-import`, - `default-import-through-star`, - // ...whose star target does have a default, and that default is a part: the - // history that tells "`export *` carries no default" from its rival. - `default-import-star-target-has-part`, - // Registration helpers by import form and module name. - `member-callee-registration`, - `default-import-registration`, - `prefixed-oracle-registrar`, - `prefixed-oracle-helper`, - `oracle-named-helper`, - // Caller-passed-body layouts. - `caller-body-trailing-name`, - `caller-body-wrapper`, - `caller-body-wrapper-reaches-part`, - // Drivers and declarations. - `let-assigned-driver`, - `for-of-driver`, - `for-of-mixed-elements`, - `declaration-spread`, - // A test imports a declared part from a JavaScript companion. - `javascript-companion-import-reaches-part`, - // Generated properties. - `named-constant-arbitrary`, - `imported-predicate`, - `predicate-ignores-input`, -] -const reach = createOracleReach(PROPERTY, STATES, { minimum: 1 }) - -/** Marks every declared state that the program enters. */ -function recordStates(program) { - reach.beginHistory() - const states = new Set() - const starCycle = (file, edges) => { - const stack = [...edges(file)] - const seen = new Set() - while (stack.length > 0) { - const next = stack.pop() - if (next === file) return true - if (seen.has(next)) continue - seen.add(next) - stack.push(...edges(next)) - } - return false - } - const starEdges = (file) => program.modules.get(file)?.stars ?? [] - const namedEdges = (file) => - (program.modules.get(file)?.exportEntries ?? []) - .filter((entry) => entry.from !== undefined) - .map((entry) => entry.from) - for (const [file, module] of program.modules) { - if (module.role === `companion` || module.role === `oracle`) { - if (starCycle(file, starEdges)) states.add(`export-star-cycle`) - if (starCycle(file, namedEdges)) states.add(`named-reexport-cycle`) - } - if (module.role === `broken`) states.add(`unparseable-companion`) - if (module.declares && module.stringKeys) - states.add(`string-keyed-declaration`) - if (module.typeNames.some((name) => module.types.has(name))) { - states.add(`type-model-declaration`) - } - for (const [name, binding] of module.bindings) { - if (binding.kind !== `fn`) continue - const cyclic = localCycle(module, name) - if (cyclic) { - states.add( - localReachesPart(module, name) - ? `mutual-recursion-reaches-part` - : `mutual-recursion-reaches-no-part`, - ) - } - } - } - for (const test of program.tests) { - if (test.table?.kind === `each`) states.add(`form-each`) - if (test.table?.kind === `for`) states.add(`form-for`) - if (test.body.kind === `call`) states.add(`form-call-body`) - if (test.body.kind === `member`) states.add(`form-member-body`) - if (test.options !== undefined) states.add(`form-options`) - } - if (program.calls.some((call) => !lintedOnItsOwn(call.file))) { - states.add(`imported-registration`) - } - if (program.tests.some((test) => test.helper !== undefined)) { - states.add(`caller-passed-body-helper`) - } - const oracle = program.modules.get(program.oracle) - for (const [, module] of program.modules) { - for (const [, link] of module.imports) { - if (link.form === `namespace`) states.add(`namespace-import`) - if (link.form !== `default`) continue - states.add(`default-import`) - const target = program.modules.get(link.from) - if ( - target !== undefined && - target.stars.length > 0 && - !target.exportEntries.some((entry) => entry.name === `default`) - ) { - states.add(`default-import-through-star`) - const starDefaultIsPart = target.stars.some((star) => { - const starModule = program.modules.get(star) - const entry = starModule?.exportEntries.find( - (candidate) => candidate.name === `default`, - ) - return entry !== undefined && starModule.parts.has(entry.local) - }) - if (starDefaultIsPart) states.add(`default-import-star-target-has-part`) - } - } - if (module.spread && module.declares) states.add(`declaration-spread`) - if (module.role === `oracle`) { - for (const test of program.tests) { - for (const ref of test.refs) { - const link = ref.kind === `import` && module.imports.get(ref.alias) - if ( - link && - link.from.endsWith(`.js`) && - program.modules.get(link.from)?.parts.has(link.imported) - ) { - states.add(`javascript-companion-import-reaches-part`) - } - } - } - } - } - for (const call of program.calls) { - const link = oracle.imports.get(call.alias) - if (link?.form === `namespace`) states.add(`member-callee-registration`) - if (link?.form === `default`) states.add(`default-import-registration`) - if (call.file.startsWith(`oracle-`)) states.add(`prefixed-oracle-registrar`) - } - const companion = program.modules.get(`c0.ts`) - for (const test of program.tests) { - const { helper, property, driver } = test - if (helper !== undefined) { - if (program.helperFile.startsWith(`oracle-`)) - states.add(`prefixed-oracle-helper`) - else if (lintedOnItsOwn(program.helperFile)) - states.add(`oracle-named-helper`) - if (helper.wrap === `timed` && helper.trailing.kind === `name`) - states.add(`caller-body-trailing-name`) - if ([`wrap`, `wrap-async`, `wrap-part`].includes(helper.wrap)) - states.add(`caller-body-wrapper`) - if (helper.wrap === `wrap-part` && companion?.parts.has(`c0f0`)) - states.add(`caller-body-wrapper-reaches-part`) - } - const arbitrary = property ?? (helper?.prop ? helper : undefined) - if (arbitrary?.arbNamed && arbitrary.constant) - states.add(`named-constant-arbitrary`) - if (property?.predicate === `imported`) states.add(`imported-predicate`) - if (arbitrary !== undefined && !arbitrary.constant && !arbitrary.readsInput) - states.add(`predicate-ignores-input`) - if (driver?.kind === `let`) states.add(`let-assigned-driver`) - if (driver?.kind === `for-of`) { - states.add(`for-of-driver`) - const reaching = driver.elements.map((name) => - localReachesPart(oracle, name), - ) - if (reaching.includes(true) && reaching.includes(false)) - states.add(`for-of-mixed-elements`) - } - } - for (const state of states) reach.reach(state) -} - -/** Whether a binding reaches itself through at least one other binding. */ -function localCycle(module, name) { - const stack = module.bindings - .get(name) - .refs.filter((ref) => ref.kind === `local`) - .map((ref) => ref.name) - const seen = new Set() - while (stack.length > 0) { - const next = stack.pop() - if (next === name) return true - if (seen.has(next)) continue - seen.add(next) - const binding = module.bindings.get(next) - if (binding === undefined) continue - for (const ref of binding.refs) - if (ref.kind === `local`) stack.push(ref.name) - } - return false -} - -/** Whether a binding's local references lead to a declared part. */ -function localReachesPart(module, name) { - const stack = [name] - const seen = new Set() - while (stack.length > 0) { - const next = stack.pop() - if (module.parts.has(next)) return true - if (seen.has(next)) continue - seen.add(next) - const binding = module.bindings.get(next) - if (binding === undefined) continue - for (const ref of binding.refs) - if (ref.kind === `local`) stack.push(ref.name) - } - return false -} - -/** The comparison for one program, on every receiving path that it runs. */ -async function checkProgram(program, paths) { - recordStates(program) - const expected = predictReports(program) - for (const path of paths) { - const actual = await path.run(program) - compareReports(path.name, actual, expected) - } -} - -const IN_PROCESS = [ - { name: `eslint`, run: lintWithEslint }, - { name: `linter`, run: (program) => lintWithLinter(program) }, -] - -/** One feature family: a fixed-seed campaign and a random-seed campaign. */ -function campaign(name, features, paths = IN_PROCESS, runs = BASE_RUNS) { - const arbitrary = programArbitrary(features) - const predicate = (program) => checkProgram(program, paths) - it(`${name}: fixed-seed campaign`, async () => { - await fc.assert(fc.asyncProperty(arbitrary, predicate), { - numRuns: oracleRuns(runs), - seed: FIXED_SEED, - }) - }, 900_000) - it(`${name}: random-seed campaign`, async () => { - await fc.assert( - fc.asyncProperty(arbitrary, predicate), - oraclePropertyOptions(runs, PROPERTY), - ) - }, 900_000) -} - -describe(`declared reach in oracle files`, () => { - campaign(`base grammar`, BASE) - // Cycles are rare in random programs, so these campaigns run more histories. - campaign(`export-star cycles`, { ...BASE, stars: true }, IN_PROCESS, 400) - campaign(`named re-export cycles`, BASE, IN_PROCESS, 400) - campaign(`call-expression bodies`, { - ...BASE, - forms: [...BASE.forms, `call`], - }) - campaign(`member-expression bodies`, { - ...BASE, - forms: [...BASE.forms, `member`], - }) - campaign(`.for tables`, { ...BASE, forms: [...BASE.forms, `for`] }) - campaign(`options before or after the body`, { - ...BASE, - forms: [...BASE.forms, `options-first`, `options-last`], - }) - campaign(`string-keyed declarations`, { - ...BASE, - stringKeys: true, - typeModels: true, - }) - campaign(`type models`, { ...BASE, typeModels: true }) - campaign(`unparseable companion`, { ...BASE, broken: true }) - campaign(`imported registration helper`, { ...BASE, registrar: true }) - campaign(`caller-passed body helpers`, { - ...BASE, - forms: [...BASE.forms, `helper-plain`, `helper-prop`], - }) - // A wrapper that uses a part needs a companion whose first function is one. - campaign( - `caller-passed body layouts: trailing arguments and wrappers`, - { - ...BASE, - forms: [...BASE.forms, `helper-plain`, `helper-prop`], - helperLayouts: true, - }, - IN_PROCESS, - 200, - ) - campaign(`registration helpers by module name and import form`, { - ...BASE, - forms: [...BASE.forms, `call`, `helper-plain`, `helper-prop`], - registrar: true, - helperNames: true, - importForms: true, - }) - // A default import through a star needs a star and a default link together. - campaign( - `namespace and default imports`, - { ...BASE, importForms: true, stars: true }, - IN_PROCESS, - 200, - ) - // A loop over a reaching and an unreached function is what tells "every - // element" from "some element", and it needs two elements of each kind. - campaign( - `let and for...of drivers`, - { ...BASE, forms: [...BASE.forms, `let-driver`, `for-of`] }, - IN_PROCESS, - 300, - ) - campaign(`declaration spreads`, { ...BASE, spreads: true }) - // A test must import a part from a JavaScript companion, which is rare. - campaign( - `JavaScript companion modules`, - { ...BASE, jsCompanions: true }, - IN_PROCESS, - 300, - ) - campaign(`generated properties: named arbitraries and predicates`, { - ...BASE, - forms: [...BASE.forms, `property`, `prop-named`, `helper-prop`], - }) - campaign(`all families`, ALL) -}) - -describe(`receiving paths`, () => { - it(`the CLI, the edit hook, and a single-file Linter agree with the model`, async () => { - for (const seed of [FIXED_SEED, FIXED_SEED + 1]) { - await fc.assert( - fc.property(programArbitrary(ALL), (program) => { - recordStates(program) - const expected = predictReports(program) - compareReports(`linter`, lintWithLinter(program), expected) - compareReports(`cli`, lintWithCli(program), expected) - compareReports(`edit-hook`, lintWithEditHook(program), expected) - }), - { numRuns: oracleRuns(4), seed }, - ) - } - }, 900_000) -}) - -describe(`metamorphic laws`, () => { - // Identifier registrations of the oracle's functions, in a single file: the - // shape whose evaluation order the reach rule must not depend on. A program - // and a permutation of its registrations are drawn together. - const orderArbitrary = programArbitrary({ - ...BASE, - companions: false, - forms: [`identifier`], - }).chain((program) => - fc - .shuffledSubarray( - program.tests.map((_, index) => index), - { minLength: program.tests.length, maxLength: program.tests.length }, - ) - .map((order) => ({ - program, - permuted: { - ...program, - tests: order.map((index) => program.tests[index]), - }, - })), - ) - const orderLaw = async ({ program, permuted }) => { - recordStates(program) - const original = await lintWithEslint(program) - const reordered = await lintWithEslint(permuted) - compareReports(`eslint, permuted`, reordered, predictReports(permuted)) - // The same subjects, except for `undeclared`, which names the first - // unreached test in source order and so moves with the order. - const bySubject = (keys) => - keys - .map((key) => (key.startsWith(`undeclared`) ? `undeclared` : key)) - .sort() - expect(bySubject(reordered)).toEqual(bySubject(original)) - } - it(`registration order does not change the reports: fixed-seed campaign`, async () => { - await fc.assert(fc.asyncProperty(orderArbitrary, orderLaw), { - numRuns: oracleRuns(3000), - seed: FIXED_SEED, - }) - }, 900_000) - it(`registration order does not change the reports: random-seed campaign`, async () => { - await fc.assert( - fc.asyncProperty(orderArbitrary, orderLaw), - oraclePropertyOptions(3000, PROPERTY), - ) - }, 900_000) - - it(`linting the same program twice in one process gives the same reports`, async () => { - await fc.assert( - fc.asyncProperty(programArbitrary(ALL), async (program) => { - recordStates(program) - const first = await lintWithEslint(program) - const second = await lintWithEslint(program) - // Rerun equality only: the model is checked by the campaigns above. - expect(second).toEqual(first) - }), - { numRuns: oracleRuns(BASE_RUNS), seed: FIXED_SEED }, - ) - }, 900_000) - - it(`a companion edit between lints gives the reports of a fresh process`, async () => { - await fc.assert( - // Only programs with such a dependency are drawn, so every run edits one. - fc.asyncProperty( - programArbitrary({ ...BASE, stars: true, acyclic: true }).filter( - (program) => dependencyOfReached(program) !== undefined, - ), - async (program) => { - const companion = dependencyOfReached(program) - recordStates(program) - const before = writeProgram( - join(root, `edit${historySeq++}`), - program, - ) - await eslint.lintFiles([before.oracle]) - // The companion gains or loses its declared part, with a new mtime. - // The same ESLint instance then lints the unchanged oracle file. - const edited = toggleDeclaration(program, companion.file) - reach.reach(`companion-edited-between-lints`) - rewriteCompanion( - join(root, `edit${historySeq - 1}`), - edited, - companion.file, - ) - const relinted = keysOf( - (await eslint.lintFiles([before.oracle])).flatMap( - (result) => result.messages, - ), - before.subjects, - ) - expect(relinted, `same instance after an edit`).toEqual( - predictReports(edited), - ) - expect(lintWithCli(edited), `fresh CLI process`).toEqual( - predictReports(edited), - ) - }, - ), - { numRuns: oracleRuns(40), seed: FIXED_SEED }, - ) - }, 900_000) -}) - -/** - * A companion that a binding reached by the oracle depends on through an import - * in its body. The rule caches a binding's reach in the module that declares - * it, so editing the companion leaves that cache stale unless the rule - * recomputes it. Re-exports are not cached and so cannot show this. - */ -function dependencyOfReached(program) { - const oracle = program.modules.get(program.oracle) - for (const test of program.tests) { - for (const ref of test.refs ?? []) { - if (ref.kind !== `import`) continue - const link = oracle.imports.get(ref.alias) - const middle = program.modules.get(link?.from) - const binding = middle?.bindings.get(link.imported) - for (const inner of binding?.refs ?? []) { - if (inner.kind !== `import`) continue - const module = program.modules.get( - middle.imports.get(inner.alias)?.from, - ) - if (module?.role === `companion` && module.bindings.size > 0) - return module - } - } - } - return undefined -} - -/** Adds or removes a declared check in one companion, as an edit would. */ -function toggleDeclaration(program, file) { - const module = program.modules.get(file) - const parts = new Map(module.parts) - if (parts.size > 0) { - parts.clear() - } else { - parts.set([...module.bindings.keys()][0], `check`) - } - const edited = { - ...module, - parts, - declares: - parts.size > 0 || - module.typeNames.length > 0 || - module.missing.length > 0, - } - return { ...program, modules: new Map(program.modules).set(file, edited) } -} - -/** Rewrites one companion in place, with an mtime after the last write. */ -function rewriteCompanion(directory, program, file) { - const { files } = renderProgram(program) - const path = join(directory, file) - writeFileSync(path, files.get(file)) - const later = new Date(Date.now() + 5000) - utimesSync(path, later, later) -} - -// -- Fixed inputs -------------------------------------------------------------- -// -// These skeletons are fixed inputs for the reviewer's repros. They go through -// the same builder, model, and paths as the generated programs, with the full -// feature set, and carry no expected answers of their own. - -const slot = ( - present, - refs = [], - { exported = false, part = `none` } = {}, -) => ({ - present, - exported, - part, - refs, -}) -const moduleOf = (fields = {}) => ({ - slots: [slot(false), slot(false), slot(false)], - stars: [], - reexports: [], - imports: [], - typeAliases: [false, false, false], - typeNames: [], - missingPart: false, - stringKeys: false, - defaultSlot: undefined, - spread: false, - js: false, - ...fields, -}) -const reg = (fields) => ({ - form: `plain`, - refs: [], - callee: 0, - literalOptions: false, - wrapped: false, - rows: [{ value: 1 }], - wrap: 0, - trailing: `literal`, - constantArb: false, - arbNamed: false, - predicate: `inline`, - readsInput: true, - elements: [0], - ...fields, -}) -const local = (index) => ({ kind: `local`, index }) -const imported = (index) => ({ kind: `import`, index }) -const typeRef = (index) => ({ kind: `type`, index }) -const fixed = (fields) => ({ - companions: [], - broken: false, - registrarName: `reg.ts`, - registrarImport: `named`, - helperName: `reg-helper.ts`, - helperImport: `named`, - oracle: { - module: moduleOf({ - slots: [slot(false), slot(false), slot(true, [], { part: `check` })], - }), - objectRefs: [], - registrarCall: false, - tests: [], - }, - ...fields, -}) - -// Two companions that star-export each other, and that re-export the same -// name from each other: a star cycle and a named re-export cycle. The oracle -// imports a name that neither exports, so its registration reaches nothing. -const exportCycle = fixed({ - companions: [ - moduleOf({ - stars: [0], - reexports: [{ target: 0, slot: 0, exportIndex: 3 }], - }), - moduleOf({ - stars: [0], - reexports: [{ target: 0, slot: 0, exportIndex: 3 }], - }), - ], - oracle: { - module: moduleOf({ - slots: [slot(false), slot(false), slot(true, [], { part: `check` })], - imports: [{ target: 0, slot: 0 }], - }), - objectRefs: [], - registrarCall: false, - tests: [reg({ refs: [imported(0)] })], - }, -}) - -// Two companions that re-export each other's names, and no star export: a -// named re-export cycle. c0 exports c0f0 from c1, and c1 exports c1f0 from c0. -const namedCycle = fixed({ - companions: [ - moduleOf({ reexports: [{ target: 0, slot: 0, exportIndex: 0 }] }), - moduleOf({ reexports: [{ target: 0, slot: 0, exportIndex: 3 }] }), - ], - oracle: { - module: moduleOf({ - slots: [slot(false), slot(false), slot(true, [], { part: `check` })], - imports: [{ target: 0, slot: 0 }], - }), - objectRefs: [], - registrarCall: false, - tests: [reg({ refs: [imported(0)] })], - }, -}) - -// Two functions that call each other, and reach the check through only one -// of them. Each registration names one function. Both orders must agree. -const mutualRecursion = fixed({ - oracle: { - module: moduleOf({ - slots: [ - slot(true, [local(1), local(2)]), - slot(true, [local(0)]), - slot(true, [], { part: `check` }), - ], - }), - objectRefs: [], - registrarCall: false, - tests: [ - reg({ form: `identifier`, callee: 0 }), - reg({ form: `identifier`, callee: 1 }), - ], - }, -}) - -// Each registration form, with one that reaches the check and one that does not. -const forms = fixed({ - oracle: { - module: moduleOf({ - slots: [slot(false), slot(false), slot(true, [], { part: `check` })], - }), - objectRefs: [], - registrarCall: false, - tests: [ - reg({ form: `call`, refs: [local(0)] }), - reg({ form: `member` }), - reg({ form: `for`, rows: [{ value: 1 }] }), - reg({ form: `options-first`, refs: [local(0)], literalOptions: true }), - reg({ form: `options-last`, literalOptions: false }), - reg({ form: `for`, rows: [{ fn: [local(0)] }] }), - ], - }, -}) - -// An unparseable companion, a string-keyed type model, and a registration -// helper from an ordinary module. -const helpers = fixed({ - broken: true, - companions: [moduleOf({})], - registrarName: `reg.ts`, - oracle: { - module: moduleOf({ - slots: [slot(false), slot(false), slot(true, [], { part: `check` })], - imports: [{ target: 1, slot: 0 }], - typeAliases: [true, false, false], - typeNames: [0], - stringKeys: true, - }), - objectRefs: [], - registrarCall: true, - tests: [reg({ refs: [imported(0)] }), reg({ refs: [typeRef(0)] })], - }, -}) - -// The second review's repros. The oracle's only function is the check `ofn2`. -const checkOnly = moduleOf({ - slots: [slot(false), slot(false), slot(true, [], { part: `check` })], -}) -const WRAP = Object.fromEntries(WRAPS.map((wrap, index) => [wrap, index])) - -// A body before a trailing timeout: the body reaches through `ofn2`, and the -// trailing argument is a literal. A unit body before a trailing `ofn2`. -const trailingArguments = fixed({ - oracle: { - module: checkOnly, - objectRefs: [], - registrarCall: false, - tests: [ - reg({ form: `helper-plain`, wrap: WRAP.timed, refs: [local(0)] }), - reg({ form: `helper-plain`, wrap: WRAP.timed, trailing: `name` }), - ], - }, -}) - -// A helper that awaits its caller's body inside its own async wrapper, a -// wrapper around a unit body, and a wrapper that uses the companion's check. -const wrappedBody = fixed({ - companions: [ - moduleOf({ - slots: [ - slot(true, [], { exported: true, part: `check` }), - slot(false), - slot(false), - ], - }), - ], - oracle: { - module: checkOnly, - objectRefs: [], - registrarCall: false, - tests: [ - reg({ form: `helper-plain`, wrap: WRAP[`wrap-async`], refs: [local(0)] }), - reg({ form: `helper-plain`, wrap: WRAP.wrap }), - reg({ form: `helper-plain`, wrap: WRAP[`wrap-part`] }), - ], - }, -}) - -// A registrar in an `oracle-` module through a namespace member call, and a -// caller-passed-body helper in an `oracle-` module with a unit body. -const machineryHelpers = fixed({ - registrarName: `oracle-reg.ts`, - registrarImport: `namespace`, - helperName: `oracle-reg-helper.ts`, - oracle: { - module: checkOnly, - objectRefs: [], - registrarCall: true, - tests: [reg({ form: `helper-plain` })], - }, -}) - -// A registrar through a default import. -const defaultRegistrar = fixed({ - registrarImport: `default`, - oracle: { - module: checkOnly, - objectRefs: [], - registrarCall: true, - tests: [reg({ refs: [local(0)] })], - }, -}) - -// Drivers: a `let` that a hook assigns, a `for...of` over the check, and a -// namespace import of a companion's exported check. -const drivers = fixed({ - companions: [ - moduleOf({ - slots: [ - slot(true, [], { exported: true, part: `check` }), - slot(false), - slot(false), - ], - }), - ], - oracle: { - module: moduleOf({ - slots: [slot(false), slot(false), slot(true, [], { part: `check` })], - imports: [{ target: 0, slot: 0, form: `namespace` }], - }), - objectRefs: [], - registrarCall: false, - tests: [ - reg({ form: `let-driver`, refs: [local(0)] }), - reg({ form: `for-of`, elements: [0] }), - reg({ refs: [imported(0)] }), - ], - }, -}) - -// A declaration that spreads its first part list, and generated properties: -// a named constant arbitrary, and an imported predicate that reads or -// ignores its input, through `fc.property` and through `.prop`. -const declarationsAndProperties = fixed({ - oracle: { - module: moduleOf({ - slots: [ - slot(true, [], { part: `model` }), - slot(false), - slot(true, [], { part: `check` }), - ], - spread: true, - }), - objectRefs: [], - registrarCall: false, - tests: [ - reg({ refs: [local(0)] }), - reg({ form: `property`, constantArb: true, arbNamed: true }), - reg({ form: `prop-named`, predicate: `imported` }), - reg({ form: `property`, predicate: `imported`, readsInput: false }), - ], - }, -}) - -// A default import of c0, which has no default of its own and star-exports -// c1, whose default is a check. `export *` carries no default, so the test -// reaches nothing. -const defaultThroughStar = fixed({ - companions: [ - moduleOf({ stars: [0] }), - moduleOf({ - slots: [slot(true, [], { part: `check` }), slot(false), slot(false)], - defaultSlot: 0, - }), - ], - oracle: { - module: moduleOf({ imports: [{ target: 0, slot: 0, form: `default` }] }), - objectRefs: [], - registrarCall: false, - tests: [reg({ refs: [imported(0)] })], - }, -}) - -// An oracle file whose only test imports the check from a JavaScript companion. -const javascriptCompanion = fixed({ - companions: [ - moduleOf({ - slots: [ - slot(true, [], { exported: true, part: `check` }), - slot(false), - slot(false), - ], - js: true, - }), - ], - oracle: { - module: moduleOf({ imports: [{ target: 0, slot: 0, form: `named` }] }), - objectRefs: [], - registrarCall: false, - tests: [reg({ refs: [imported(0)] })], - }, -}) - -describe(`fixed inputs through every receiving path`, () => { - const paths = [ - { name: `eslint`, run: lintWithEslint }, - { name: `linter`, run: (program) => lintWithLinter(program) }, - { name: `cli`, run: (program) => lintWithCli(program) }, - { name: `edit-hook`, run: (program) => lintWithEditHook(program) }, - ] - for (const [name, skeleton] of [ - [`export-star and named re-export cycles`, exportCycle], - [`named re-export cycle only`, namedCycle], - [`mutual recursion, in both orders`, mutualRecursion], - [ - `mutual recursion, reversed`, - { - ...mutualRecursion, - oracle: { - ...mutualRecursion.oracle, - tests: [...mutualRecursion.oracle.tests].reverse(), - }, - }, - ], - [`each registration form`, forms], - [`broken companion, string-keyed type model, registration helper`, helpers], - [`a caller-passed body before a trailing argument`, trailingArguments], - [`a caller-passed body inside the helper's wrapper`, wrappedBody], - [`helpers in oracle- machinery modules`, machineryHelpers], - [`a registrar through a default import`, defaultRegistrar], - [`let, for...of, and namespace drivers`, drivers], - [ - `a spread declaration and generated properties`, - declarationsAndProperties, - ], - [`a check imported from a JavaScript companion`, javascriptCompanion], - [`a default import through a star export`, defaultThroughStar], - ]) { - it(`${name}`, async () => { - const program = buildProgram(skeleton, ALL) - await checkProgram(program, paths) - }, 300_000) - } -}) - -// The reach ledger is checked once every campaign above has run. A state that -// no generated history entered fails the file, so its law is not claimed. -afterAll(() => { - reach.assertReached() -}) - -// A dependency chain. The oracle imports `c1f0`, which imports the check -// `c0f0`. Editing `c0` between two lints must change the oracle's reports, -// although `c1` is unchanged: its reach was computed by the earlier lint. -const chain = fixed({ - companions: [ - moduleOf({ - slots: [ - slot(true, [], { exported: true, part: `check` }), - slot(false), - slot(false), - ], - }), - moduleOf({ - slots: [ - slot(true, [imported(0)], { exported: true }), - slot(false), - slot(false), - ], - imports: [{ target: 0, slot: 0 }], - }), - ], - oracle: { - module: moduleOf({ imports: [{ target: 1, slot: 0 }] }), - objectRefs: [], - registrarCall: false, - tests: [reg({ refs: [imported(0)] })], - }, -}) - -describe(`stale reach across a dependency chain`, () => { - it(`an edit to a transitive companion changes the reports of the next lint`, async () => { - const program = buildProgram(chain, ALL) - const before = writeProgram(join(root, `chain${historySeq++}`), program) - compareReports( - `eslint`, - await lintInstance(before.oracle, before.subjects), - predictReports(program), - ) - const edited = toggleDeclaration(program, `c0.ts`) - rewriteCompanion( - before.oracle.slice(0, before.oracle.lastIndexOf(`/`)), - edited, - `c0.ts`, - ) - compareReports( - `eslint after an edit`, - await lintInstance(before.oracle, before.subjects), - predictReports(edited), - ) - }, 300_000) -}) - -/** Lints one file on the long-lived instance; returns its keys. */ -async function lintInstance(file, subjects) { - const results = await eslint.lintFiles([file]) - return keysOf( - results.flatMap((result) => result.messages), - subjects, - ) -} diff --git a/scripts/eslint-plugin-oracle/registration.js b/scripts/eslint-plugin-oracle/registration.js deleted file mode 100644 index 14513c0d84..0000000000 --- a/scripts/eslint-plugin-oracle/registration.js +++ /dev/null @@ -1,199 +0,0 @@ -/** - * How the oracle rules recognize a test registration and the shared helpers - * from oracle-config. Every rule uses these, so that the rules agree on what - * a test is. - */ - -export const testRoots = new Set([`describe`, `fcTest`, `it`, `suite`, `test`]) -export const disablingModifiers = new Set([`fixme`, `skip`, `todo`]) -export const conditionalModifiers = new Set([`runIf`, `skipIf`]) -export const aliasModifiers = new Set([ - ...disablingModifiers, - ...conditionalModifiers, - `only`, -]) -// 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. -export const registrationMembers = new Set([ - ...aliasModifiers, - `concurrent`, - `describe`, - `each`, - `fails`, - `for`, - `parallel`, - `prop`, - `sequential`, - `serial`, -]) -// The shared helpers that return a test registration or its `skip` form. -export const selectionHelpers = new Set([ - `oracleCalibrationTest`, - `oracleReplayTest`, -]) - -/** The root identifier and member names of a callee such as `it.skip.prop(...)`. */ -export 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.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 { - break - } - } - return { - root: node.type === `Identifier` ? node.name : undefined, - rootNode: node, - names, - } -} - -/** - * Whether an identifier names a test function: `it`, `test`, and the - * other test roots, a renamed import of one (`import { it as vitestIt }`), - * or a constant bound to one through a selection helper or a member chain - * (`const fixedCase = oracleReplayTest(it)`). A registration made through - * such a constant is checked as a registration of its test. - */ -export function isTestRoot(node, sourceCode, seen = new Set()) { - if (node?.type !== `Identifier`) return false - if (testRoots.has(node.name)) return true - const definition = findVariable(sourceCode.getScope(node), node.name)?.defs[0] - if (definition === undefined || seen.has(definition.node)) return false - seen.add(definition.node) - if (definition.type === `ImportBinding`) { - const imported = definition.node.imported - return ( - imported !== undefined && testRoots.has(imported.name ?? imported.value) - ) - } - return ( - definition.type === `Variable` && - definition.parent.kind === `const` && - definition.node.init !== null && - isTestRoot(calleeChain(definition.node.init).rootNode, sourceCode, seen) - ) -} - -export function isFunction(node) { - return ( - node?.type === `ArrowFunctionExpression` || - node?.type === `FunctionExpression` - ) -} - -export function findVariable(scope, name) { - for (let current = scope; current; current = current.upper) { - const variable = current.set.get(name) - if (variable !== undefined) return variable - } - return undefined -} - -/** Whether `source` is an import specifier of the shared oracle-config module. */ -export function isOracleConfigSource(source) { - return /(^|\/)oracle-(config|declaration)(\.js)?$/.test(source) -} - -/** A call to one of `names`, imported from oracle-config. */ -export function isSharedHelperCall(node, names, sourceCode) { - if ( - node?.type !== `CallExpression` || - node.callee.type !== `Identifier` || - !names.has(node.callee.name) - ) { - return false - } - const definition = findVariable(sourceCode.getScope(node), node.callee.name) - ?.defs[0] - return ( - definition?.type === `ImportBinding` && - isOracleConfigSource(definition.parent.source.value) - ) -} - -/** - * The test that a call registers, as `{ body }`, or undefined when the call - * registers no test. A suite (`describe`) registers no test of its own. The - * body is the function, or the name of the function, that the test runs. - */ -export function registeredTest(node, sourceCode) { - if (node.parent.type === `CallExpression` && node.parent.callee === node) { - // `it.each(table)` is the callee of the registration, not one itself. - return undefined - } - const { root, rootNode, names } = calleeChain(node.callee) - if ( - !isTestRoot(rootNode, sourceCode) || - root === `describe` || - root === `suite` || - names.includes(`describe`) || - !names.every((member) => registrationMembers.has(member)) - ) { - return undefined - } - const body = registrationBody(node.arguments.slice(1), sourceCode) - return body === undefined ? undefined : { body } -} - -/** - * The argument that a registration runs. Vitest takes `(name, fn, options)` or - * `(name, options, fn)`, so the body is the function wherever it is. Without a - * function, the body is a call or member expression that yields one, then an - * identifier. An options object, a number, or a value that is not a function - * binding is never the body while another argument can be. - */ -function registrationBody(args, sourceCode) { - return ( - args.find(isFunction) ?? - args.find( - (argument) => - argument.type === `Identifier` && - isFunctionBinding(argument, sourceCode), - ) ?? - args.find( - (argument) => - !plainValueTypes.has(argument.type) && argument.type !== `Identifier`, - ) ?? - args.find((argument) => argument.type === `Identifier`) - ) -} - -// Argument types that carry data, not behavior, so they can be options. -const plainValueTypes = new Set([ - `Literal`, - `ObjectExpression`, - `ArrayExpression`, - `TemplateLiteral`, -]) - -/** - * Whether an identifier names a function, or may. A binding that holds a plain - * value is not a function. An import, a parameter, or a call result may be one. - */ -function isFunctionBinding(identifier, sourceCode) { - const definition = findVariable( - sourceCode.getScope(identifier), - identifier.name, - )?.defs[0] - if (definition?.type === `FunctionName`) return true - if (definition?.type === `Variable`) { - const init = definition.node.init - return init !== null && !plainValueTypes.has(init.type) - } - return definition !== undefined -} diff --git a/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js b/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js deleted file mode 100644 index 21c226bd1c..0000000000 --- a/scripts/eslint-plugin-oracle/replay-observation-oracle.test.js +++ /dev/null @@ -1,250 +0,0 @@ -/** - * A completed-history count is output data, not a generated-property budget. - * The maintainer's 2026-10-28 decision permits the typed OracleReplayWitness - * record in its shared writer only when every value use serializes that record. - * AGENTS.md still requires one-run unpinned property options to be reported. - * A type annotation cannot excuse an options alias, escape, mutation or second - * use. The initializer must be a plain data record with witness fields; custom - * serialization hooks can receive the record without a binding reference. - * This boundary owns only that distinction, not all no-disabled syntax. - * - * The finite source grammar varies location, annotation, value use, type-only - * queries, JSON bindings and record spelling. Its intent model knows whether - * the authored program is exclusively - * recording a witness; it never parses source or invokes the rule. ESLint's - * real TypeScript parser and no-disabled rule supply the report observation. - * The comparison runs after linting and retains every report, including parser - * errors. There is no random sampling, filesystem fixture or external process. - * Global JSON.stringify is the supported serializer; its unmodified standard - * behavior is a trusted premise. Global monkeypatches are outside this law. - * Other serializers, - * destructuring and intermodule flow are deliberately outside the allowance. - */ -import assert from 'node:assert/strict' -import tsParser from '@typescript-eslint/parser' -import { Linter } from 'eslint' -import { afterAll, test } from 'vitest' -import { declareOracle } from '../../packages/db/tests/oracle-declaration.js' -import { createOracleReach } from '../../packages/db/tests/oracle-reach.js' -import noDisabled from './rules/no-disabled.js' - -const writer = '/repo/packages/db/tests/oracle-replay-witness.ts' -const flows = [ - 'serialize', - 'budget', - 'both', - 'alias', - 'escape', - 'reassign', - 'shadow-json', - 'read-field', - 'replacer', - 'export', - 'runtime-typeof', -] -const jsonBindings = [ - 'builtin', - 'type-alias', - 'interface', - 'import-type', - 'import-type-specifier', - 'ambient-value', - 'outer-value-inner-type', -] -const programs = [] -for (const location of ['writer', 'lookalike']) - for (const annotation of ['witness', 'other', 'none']) - for (const flow of flows) - for (const name of ['record', 'serializedProof']) - for (const typeQuery of ['none', 'direct', 'qualified', 'before']) - for (const jsonBinding of jsonBindings) { - // An export inside a function is illegal TypeScript. Other axes - // remain independent; the grammar never relies on parser recovery. - if (flow === 'export' && jsonBinding === 'outer-value-inner-type') - continue - programs.push({ - location, - annotation, - flow, - name, - typeQuery, - jsonBinding, - initializer: 'plain', - }) - } -// Custom hooks challenge the otherwise admissible recording programs. Crossing -// them with already rejected uses would hide whether this new guard runs. -for (const program of [...programs]) - if ( - program.location === 'writer' && - program.annotation === 'witness' && - program.flow === 'serialize' && - !['ambient-value', 'outer-value-inner-type'].includes(program.jsonBinding) - ) - for (const initializer of [ - 'spread', - 'method', - 'accessor', - 'prototype', - 'computed', - ]) - programs.push({ ...program, initializer }) - -// Only a dedicated recording program emits a count observation. Every other -// authored program has an unpinned literal count whose enforcement must remain. -function expectedReports(program) { - return program.location === 'writer' && - program.annotation === 'witness' && - program.flow === 'serialize' && - program.jsonBinding !== 'ambient-value' && - program.jsonBinding !== 'outer-value-inner-type' && - program.initializer === 'plain' - ? [] - : ['singleRun'] -} - -function source(program) { - const { name, annotation, flow } = program - const type = - annotation === 'none' - ? '' - : `: ${annotation === 'witness' ? 'OracleReplayWitness' : 'OtherRecord'}` - const hook = - 'toJSON(this: OracleReplayWitness) { fc.assert(property, this); return null }' - const custom = - program.initializer === 'spread' - ? '...budgetHook,' - : program.initializer === 'prototype' - ? '__proto__: budgetHook,' - : program.initializer === 'method' - ? `${hook},` - : program.initializer === 'accessor' - ? 'get toJSON() { return function() { fc.assert(property, this); return null } },' - : program.initializer === 'computed' - ? '["toJSON"]() { fc.assert(property, this); return null },' - : '' - const record = `const budgetHook = { ${hook} };\n${flow === 'export' ? 'export ' : ''}${flow === 'reassign' ? 'let' : 'const'} ${name}${type} = { ${custom} numRuns: 1, failed: false }` - const serialize = `appendFileSync(channel, JSON.stringify(${name}));` - const use = - flow === 'serialize' || flow === 'export' - ? `${serialize}\n${serialize}` - : flow === 'budget' - ? `fc.assert(property, ${name})` - : flow === 'both' - ? `${serialize}\nfc.assert(property, ${name})` - : flow === 'alias' - ? `const options = ${name}; fc.assert(property, options)` - : flow === 'escape' - ? `send(${name})` - : flow === 'reassign' - ? `${name} = replacement; ${serialize}` - : flow === 'shadow-json' - ? serialize - : flow === 'replacer' - ? `JSON.stringify(${name}, (_key, value) => { fc.assert(property, value); return value })` - : flow === 'runtime-typeof' - ? `${serialize}\ntypeof ${name}` - : `${serialize}\nfc.assert(property, { numRuns: ${name}.numRuns })` - const query = - program.typeQuery === 'none' - ? '' - : `type Snapshot = typeof ${name}${program.typeQuery === 'qualified' ? '.numRuns' : ''};` - const body = `${record};\n${program.typeQuery === 'before' ? query : ''}\n${use};\n${program.typeQuery === 'before' ? '' : query}` - const rendered = - flow === 'shadow-json' ? `function encode(JSON) { ${body} }` : body - if (program.jsonBinding === 'outer-value-inner-type') - return `function outer(JSON) { { type JSON = { marker: true }; ${rendered} } }` - const prefix = - program.jsonBinding === 'type-alias' - ? 'type JSON = { marker: true };' - : program.jsonBinding === 'interface' - ? 'interface JSON { marker: true };' - : program.jsonBinding === 'import-type' - ? 'import type JSON from "types";' - : program.jsonBinding === 'import-type-specifier' - ? 'import { type JSON } from "types";' - : program.jsonBinding === 'ambient-value' - ? 'declare const JSON: unknown;' - : '' - return `${prefix}\n${rendered}` -} - -// This driver asks the actual rule for all reports, rather than classifying its -// AST ourselves. Binding spelling must not change the observation. -function reports(program) { - return new Linter({ cwd: '/repo' }) - .verify( - source(program), - [ - { - files: ['**/*.ts'], - languageOptions: { - parser: tsParser, - parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - }, - plugins: { oracle: { rules: { 'no-disabled': noDisabled } } }, - rules: { 'oracle/no-disabled': 'error' }, - }, - ], - { - filename: - program.location === 'writer' - ? writer - : '/repo/packages/db/tests/witness-output.ts', - }, - ) - .map((report) => report.messageId ?? `parse:${report.message}`) -} - -function checkReports(program, observed) { - assert.deepEqual(observed, expectedReports(program), JSON.stringify(program)) -} - -const stateOf = ({ - location, - annotation, - flow, - typeQuery, - jsonBinding, - initializer, -}) => - `${location}/${annotation}/${flow}/${typeQuery}/${jsonBinding}/${initializer}` -const states = [...new Set(programs.map(stateOf))] -const reach = createOracleReach('lint.replay-observation', states, { - replaying: false, -}) -let campaignEntered = false -afterAll(() => { - // Calibrations can be selected independently. Once any source comparison - // starts, the finite campaign still owes every declared reach state. - if (campaignEntered) reach.assertReached() -}) -declareOracle({ - check: [checkReports], - model: [expectedReports], - grammar: [programs], -}) - -test.each(programs)( - 'separates replay observations from budgets %j', - (program) => { - campaignEntered = true - reach.beginHistory() - const observed = reports(program) - reach.reach(stateOf(program)) - checkReports(program, observed) - }, -) - -test.each(['missing-report', 'spurious-report'])( - 'rejects wrong report observation %s', - (fault) => { - const program = programs.find( - (candidate) => - (expectedReports(candidate).length === 0) === - (fault === 'spurious-report'), - ) - const wrong = expectedReports(program).length === 0 ? ['singleRun'] : [] - assert.throws(() => checkReports(program, wrong)) - }, -) diff --git a/scripts/eslint-plugin-oracle/rules/no-disabled.js b/scripts/eslint-plugin-oracle/rules/no-disabled.js deleted file mode 100644 index 2967ac95ef..0000000000 --- a/scripts/eslint-plugin-oracle/rules/no-disabled.js +++ /dev/null @@ -1,767 +0,0 @@ -/** - * 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`, `.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 - * `.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 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`, directly or through a constant bound to its - * result, or through a renamed import such as `import { it as vitestIt }`, - * 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. - * - 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 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. - * - A private replay variable: a `process.env` read whose name ends in - * `_SEED`, `_PATH`, `_PROPERTY`, or `_RUNS`, other than the shared - * `TANSTACK_DB_ORACLE_SEED`, `TANSTACK_DB_ORACLE_PATH`, and - * `TANSTACK_DB_ORACLE_PROPERTY`. The replay manifest, the guarded replay - * runner, and the retired-variable check see only the shared variables. - * - * 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 run count - * or seed held in a variable that is not a constant bound to a number, a call - * to a local helper that registers tests under a condition, or a table built - * by a function that returns different items in different runs. Reviewers - * still check those. - */ - -import { - aliasModifiers, - calleeChain, - conditionalModifiers, - disablingModifiers, - findVariable, - isFunction, - isSharedHelperCall, - isTestRoot, - registrationMembers, - selectionHelpers, - testRoots, -} from '../registration.js' - -const replayConditions = new Set([`oracleReplayRequested`, `oracleReplaySkips`]) -// oracle-config implements replay selection, so its options may carry a -// replay path from configuration. -const replaySelectionModule = - /(^|[/\\])packages[/\\]db[/\\]tests[/\\]oracle-config\.ts$/ -// The variables that select a replay. Any other variable whose name ends -// in one of these suffixes is a private replay selection. -const sharedReplayVariables = new Set([ - `TANSTACK_DB_ORACLE_PATH`, - `TANSTACK_DB_ORACLE_PROPERTY`, - `TANSTACK_DB_ORACLE_SEED`, -]) -const replayVariableName = /_(PATH|PROPERTY|RUNS|SEED)$/ -const runHelpers = new Set([ - `oraclePropertyOptions`, - `oracleRandomParameters`, - `oracleRuns`, -]) - -/** 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`) - ) -} - -/** 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`) { - if ( - node.type === `CallExpression` && - node.callee.type === `MemberExpression` && - conditionalModifiers.has(node.callee.property.name) - ) { - return { name: node.callee.property.name, args: node.arguments } - } - node = node.type === `CallExpression` ? node.callee : node.object - } - return undefined -} - -/** Exactly `.skipIf()`, not negated. */ -function isReplaySelection({ name, args }, sourceCode) { - return ( - name === `skipIf` && - args.length === 1 && - isSharedHelperCall(args[0], replayConditions, sourceCode) - ) -} - -/** - * 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 returns(statement) { - if (statement === null || statement === undefined) return false - if (statement.type === `ReturnStatement`) return true - if (statement.type === `BlockStatement`) { - return returns(statement.body.at(-1)) - } - if (statement.type === `IfStatement`) { - return returns(statement.consequent) && returns(statement.alternate) - } - return false -} - -function isEmptyArray(node) { - return node?.type === `ArrayExpression` && node.elements.length === 0 -} - -/** - * The conditions that decide which items a loop's iterable or an `.each()` - * table holds, each with the value under which the items register. A branch - * that is `[]` registers nothing, a `.filter` callback that reads - * `process.env` filters by environment, and a constant is read through to - * its initializer. - */ -function iterableConditions(node, sourceCode, seen = new Set()) { - if (node === null || node === undefined || seen.has(node)) return [] - seen.add(node) - if (node.type === `Identifier`) { - const definition = findVariable(sourceCode.getScope(node), node.name) - ?.defs[0] - return definition?.type === `Variable` && definition.parent.kind === `const` - ? iterableConditions(definition.node.init, sourceCode, seen) - : [] - } - if (node.type === `ConditionalExpression`) { - const conditions = [] - if (isEmptyArray(node.alternate)) { - conditions.push({ test: node.test, registersWhen: true }) - } else if (isEmptyArray(node.consequent)) { - conditions.push({ test: node.test, registersWhen: false }) - } - return [ - ...conditions, - ...iterableConditions(node.consequent, sourceCode, seen), - ...iterableConditions(node.alternate, sourceCode, seen), - ] - } - if (node.type === `LogicalExpression`) { - return [ - { - test: node.left, - registersWhen: node.operator === `&&` ? true : undefined, - }, - ...iterableConditions(node.right, sourceCode, seen), - ] - } - if ( - node.type === `CallExpression` && - node.callee.type === `MemberExpression` && - node.callee.property.name === `filter` - ) { - const [callback] = node.arguments - const readsEnvironment = - callback !== undefined && - /process\.env/.test(sourceCode.getText(callback)) - return [ - ...(readsEnvironment - ? [{ test: callback, registersWhen: undefined }] - : []), - ...iterableConditions(node.callee.object, sourceCode, seen), - ] - } - return [] -} - -/** The table of an `.each(table)` call in a registration's callee chain. */ -function eachTable(callee) { - let node = callee - while (node.type === `CallExpression` || node.type === `MemberExpression`) { - if ( - node.type === `CallExpression` && - node.callee.type === `MemberExpression` && - node.callee.property.name === `each` - ) { - return node.arguments[0] - } - node = node.type === `CallExpression` ? node.callee : node.object - } - return undefined -} - -/** - * 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 returns. `registersWhen` is undefined when no single value - * decides it, as for `??`. - */ -function guardingConditions(node, sourceCode) { - const conditions = [] - let child = node - for (let parent = node.parent; parent; parent = parent.parent) { - if (parent.type === `ForOfStatement` && child === parent.body) { - conditions.push(...iterableConditions(parent.right, sourceCode)) - } - if ( - (parent.type === `IfStatement` || - parent.type === `ConditionalExpression`) && - child !== parent.test - ) { - conditions.push({ - test: parent.test, - registersWhen: child === parent.consequent, - }) - } else if (parent.type === `LogicalExpression` && child === parent.right) { - conditions.push({ - test: parent.left, - registersWhen: - parent.operator === `&&` - ? true - : parent.operator === `||` - ? false - : undefined, - }) - } 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` && returns(statement.consequent)) { - conditions.push({ test: statement.test, registersWhen: false }) - } - } - } - child = parent - } - 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 - 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 ( - node.type === `BinaryExpression` && - node.operator === `!==` && - node.left.type === `Identifier` && - node.left.name === `browserName` && - isStringLike(node.right) - ) -} - -/** - * 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) { - const [first, second] = args - if (first === undefined) return true - if (second === undefined && isStringLike(first)) return false - if (second === undefined || isStringLike(second)) { - return !isBrowserCondition(first) - } - 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 - ) -} - -function isNumberLiteral(node) { - if (node?.type === `UnaryExpression` && node.operator === `-`) { - return isNumberLiteral(node.argument) - } - return node?.type === `Literal` && typeof node.value === `number` -} - -function isProcessEnv(node) { - return ( - node?.type === `MemberExpression` && - !node.computed && - node.object.type === `Identifier` && - node.object.name === `process` && - node.property.name === `env` - ) -} - -/** The name in `process.env.NAME` or `process.env['NAME']`. */ -function environmentKey(node) { - if (!isProcessEnv(node.object)) return undefined - if (!node.computed) return node.property.name - return node.property.type === `Literal` && - typeof node.property.value === `string` - ? node.property.value - : undefined -} - -function isPrivateReplayVariable(name) { - return replayVariableName.test(name) && !sharedReplayVariables.has(name) -} - -/** The number literal that `node` is, or that a constant `node` names. */ -function constantNumber(node, sourceCode) { - if (isNumberLiteral(node)) return node - if (node?.type !== `Identifier`) return undefined - const definition = findVariable(sourceCode.getScope(node), node.name)?.defs[0] - return definition?.type === `Variable` && - definition.parent.kind === `const` && - isNumberLiteral(definition.node.init) - ? definition.node.init - : undefined -} - -/** A number literal, or a constant bound to one. */ -function isFixedSeed(node, sourceCode) { - return constantNumber(node, sourceCode) !== undefined -} - -function propertyName(property) { - if (property.type !== `Property` || property.computed) return undefined - return property.key.type === `Identifier` - ? property.key.name - : 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 && isRecordedPath(path.value)) 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`) -} - -/** - * The shared replay writer serializes completed-history counts. They are - * observations, not run options. A type name alone proves nothing: every - * value reference must pass directly to the unshadowed one-argument standard - * serializer. Aliases, writes, callbacks and additional uses retain reporting. - */ -function isSerializedReplayWitness(object, filename, sourceCode) { - if ( - !/(^|[/\\])packages[/\\]db[/\\]tests[/\\]oracle-replay-witness\.ts$/.test( - filename, - ) - ) - return false - // Standard JSON invokes own or inherited toJSON hooks. A spread, accessor, - // method or prototype field can expose the record through `this` without - // another binding reference. Only the plain witness data shape qualifies. - const fields = new Set([`property`, `seed`, `path`, `numRuns`, `failed`]) - if ( - !object.properties.every( - (property) => - property.type === `Property` && - property.kind === `init` && - !property.method && - !property.computed && - fields.has(propertyName(property)), - ) - ) - return false - const declaration = object.parent - if ( - declaration.type !== `VariableDeclarator` || - declaration.init !== object || - declaration.id.type !== `Identifier` - ) - return false - if ( - declaration.parent.type === `VariableDeclaration` && - declaration.parent.parent.type === `ExportNamedDeclaration` - ) - return false - const id = declaration.id - const type = id.typeAnnotation?.typeAnnotation - if ( - type?.type !== `TSTypeReference` || - type.typeName.type !== `Identifier` || - type.typeName.name !== `OracleReplayWitness` - ) - return false - const variable = findVariable(sourceCode.getScope(object), id.name) - if (variable === null || variable === undefined) return false - const uses = variable.references.filter(({ identifier }) => { - if (identifier === id) return false - // A TS typeof query resolves a value symbol, so scope-manager marks it as - // a value reference. Its syntax still has no runtime consumption. - let name = identifier - while (name.parent.type === `TSQualifiedName` && name.parent.left === name) - name = name.parent - return name.parent.type !== `TSTypeQuery` || name.parent.exprName !== name - }) - return ( - uses.length > 0 && - uses.every((reference) => { - if (!reference.isReadOnly()) return false - const call = reference.identifier.parent - if ( - call.type !== `CallExpression` || - call.optional || - call.arguments.length !== 1 || - call.arguments[0] !== reference.identifier - ) - return false - const callee = call.callee - if ( - callee.type !== `MemberExpression` || - callee.computed || - callee.optional || - callee.object.type !== `Identifier` || - callee.object.name !== `JSON` || - callee.property.type !== `Identifier` || - callee.property.name !== `stringify` - ) - return false - // Type-only names do not shadow the standard runtime JSON binding. Keep - // walking: an inner type alias can hide an outer runtime parameter from - // findVariable, although that parameter still supplies JSON.stringify. - for ( - let scope = sourceCode.getScope(call); - scope !== null; - scope = scope.upper - ) { - const json = scope.set.get(`JSON`) - if ( - json?.defs.some( - (definition) => - definition.isVariableDefinition !== false && - !( - definition.type === `ImportBinding` && - (definition.parent?.importKind === `type` || - definition.node.importKind === `type`) - ), - ) - ) - return false - } - return true - }) - ) -} - -/** - * Whether a call skips the test that it runs in through the test context, - * as in `it('law', (ctx) => { ctx.skip() })`. - */ -function isContextSkip(node, sourceCode) { - const { callee } = node - if ( - callee.type !== `MemberExpression` || - callee.property.type !== `Identifier` || - callee.property.name !== `skip` || - callee.object.type !== `Identifier` - ) { - 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` && - registration.arguments.includes(fn) && - isTestRoot(calleeChain(registration.callee).rootNode, sourceCode) - ) -} - -/** - * Whether a test function is passed around as a value: assigned, collected, - * returned, or passed to any call except a shared selection helper. - */ -function isPassedAsValue(node) { - const { parent } = node - switch (parent.type) { - case `VariableDeclarator`: - return parent.init === node - case `AssignmentExpression`: - return parent.right === node - case `ArrayExpression`: - case `ReturnStatement`: - case `SpreadElement`: - return true - case `Property`: - return parent.value === node && parent.parent.type === `ObjectExpression` - case `ConditionalExpression`: - return parent.test !== node - case `LogicalExpression`: - return true - case `ArrowFunctionExpression`: - return parent.body === node - case `CallExpression`: - return ( - parent.arguments.includes(node) && - !( - parent.callee.type === `Identifier` && - selectionHelpers.has(parent.callee.name) && - parent.arguments[0] === node - ) - ) - default: - return false - } -} - -/** @type {import('eslint').Rule.RuleModule} */ -export default { - meta: { - type: `problem`, - docs: { - 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 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.`, - aliasTest: `\`{{name}}\` is passed around as a value, so the rule cannot see what it registers or switches off. Call it directly, or select a replay with \`oracleReplayTest({{name}}, 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.`, - replayVariable: `\`{{name}}\` selects a replay or a run count outside the shared variables, so the replay manifest, the guarded replay runner, and the retired-variable check cannot see it. Take the options from \`oraclePropertyOptions\` or \`oracleRandomParameters\` in oracle-config, which read TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY, and scale run counts with \`oracleRuns\`.`, - 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) { - 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` && - parent.parent.type === `ObjectExpression` && - (isPinnedOrObservation(parent.parent, sourceCode) || - isSerializedReplayWitness( - parent.parent, - context.filename, - 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 - } - if (isContextSkip(node, sourceCode)) { - context.report({ node, messageId: `skip`, data: { name: `skip` } }) - return - } - const { root, rootNode, names } = calleeChain(node.callee) - if (runHelpers.has(root) && names.length === 0) { - if ( - isSingleRunLiteral(constantNumber(node.arguments[0], sourceCode)) && - !isPinnedRunCount(node) - ) { - context.report({ node: node.arguments[0], messageId: `singleRun` }) - } - return - } - if ( - !isTestRoot(rootNode, sourceCode) || - !names.every((member) => registrationMembers.has(member)) - ) { - return - } - const unguarded = [ - ...guardingConditions(node, sourceCode), - ...iterableConditions(eachTable(node.callee), sourceCode), - ].every((condition) => keepsOrdinaryRun(condition, sourceCode)) - if (!unguarded) { - context.report({ node, messageId: `guarded` }) - return - } - if (names.includes(`only`)) { - context.report({ node: node.callee, messageId: `only` }) - return - } - const conditional = registrationCondition(node.callee) - if (conditional !== undefined) { - if (!isReplaySelection(conditional, sourceCode)) { - context.report({ - node, - messageId: `conditional`, - data: { name: conditional.name }, - }) - } - return - } - const disabled = names.find((name) => disablingModifiers.has(name)) - if (disabled !== undefined && disablesBody(node.arguments)) { - context.report({ node, messageId: `skip`, data: { name: disabled } }) - } - }, - Identifier(node) { - if (!testRoots.has(node.name) || !isPassedAsValue(node)) return - // A local variable or parameter that shares a test function's name - // is not that function. - const definition = findVariable(sourceCode.getScope(node), node.name) - ?.defs[0] - if (definition !== undefined && definition.type !== `ImportBinding`) { - return - } - context.report({ - node, - messageId: `aliasTest`, - data: { name: node.name }, - }) - }, - MemberExpression(node) { - const variable = environmentKey(node) - if ( - variable !== undefined && - isPrivateReplayVariable(variable) && - !replaySelectionModule.test(context.filename) - ) { - context.report({ - node, - messageId: `replayVariable`, - data: { name: variable }, - }) - } - // 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 { rootNode, names } = calleeChain(node) - const name = names.find((member) => aliasModifiers.has(member)) - if (isTestRoot(rootNode, sourceCode) && name !== undefined) { - context.report({ node, messageId: `alias`, data: { name } }) - } - }, - VariableDeclarator(node) { - if ( - node.id.type !== `ObjectPattern` || - !isProcessEnv(node.init) || - replaySelectionModule.test(context.filename) - ) { - return - } - for (const property of node.id.properties) { - const name = - property.type === `Property` ? propertyName(property) : undefined - if (name !== undefined && isPrivateReplayVariable(name)) { - context.report({ - node: property, - messageId: `replayVariable`, - data: { name }, - }) - } - } - }, - Property(node) { - if ( - replaySelectionModule.test(context.filename) || - propertyName(node) !== `numRuns` - ) { - return - } - // A constant bound to a number literal counts as that literal. - const count = constantNumber(node.value, sourceCode) - if (count === undefined || isPinnedRunCount(node.value)) return - context.report({ - node, - messageId: isSingleRunLiteral(count) ? `singleRun` : `literalRuns`, - }) - }, - } - }, -} diff --git a/scripts/eslint-plugin-oracle/rules/no-unit-test.js b/scripts/eslint-plugin-oracle/rules/no-unit-test.js deleted file mode 100644 index 60cf8c71e4..0000000000 --- a/scripts/eslint-plugin-oracle/rules/no-unit-test.js +++ /dev/null @@ -1,1305 +0,0 @@ -import { existsSync, readFileSync, statSync } from 'node:fs' -import { basename, dirname, join, relative, resolve, sep } from 'node:path' -import { parseForESLint } from '@typescript-eslint/parser' -import { - findVariable, - isFunction, - isOracleConfigSource, - registeredTest, -} from '../registration.js' - -/** - * An oracle's expected results come from its model, and its check compares - * production with them. A test that compares production with a value that a - * person wrote down is a unit test. Its expectation has no independent - * source, so it is not oracle coverage, even in a file named for an oracle. - * - * Each oracle file declares its parts with `declareOracle` from - * oracle-declaration: - * - * declareOracle({ check: [checkHistory], model: [modelHistory] }) - * - * The check compares production observations with the model, or with a - * second formulation. The grammar is the generators that produce legal - * histories. Each is optional: a differential or metamorphic oracle has no - * model, and a companion module can declare only the part that its importers - * use. Every test in the file must reach a declared part: - * - * - A generated property or a fixed history reaches the check. - * - A model pin reaches the model. It checks one model rule against its - * authority. - * - A calibration reaches the check. It shows that the check rejects a - * wrong answer. - * - A grammar calibration reaches the grammar. It shows that the generators - * reach the states that the laws need. - * - * A generated property counts as its own check: `fc.property(...)` or - * `fc.asyncProperty(...)` from fast-check, run by the test directly or - * through code it calls, or a `.prop([...])` registration. Its predicate - * states a law over inputs that nobody wrote down. It does not count when all - * its arbitraries are `fc.constant` or `fc.constantFrom`, written inline or - * bound to a const name, or when its predicate never reads its input: then it - * checks written values. A predicate is read where it is defined, in this file - * or in a test module that the file imports. One that the rule cannot read, as - * from a package, counts. - * - * A test reaches a part when its body uses the part, or uses a function or - * value that reaches it. The rule follows bindings through the file and - * through relative imports of other test modules, so a driver in a companion - * module that calls the check counts. An import is followed whether it is - * named, a default, or a namespace whose member the code reads; `export *` - * does not carry a default. A `let` holds each value that code assigns to it, - * such as a driver that a hook builds. A `for...of` loop variable takes each - * element of its array in turn, and the loop registers one test per element, - * so every element must reach. It does not follow imports of - * production: `src/` modules of a published package, which declare no oracle - * part. A private package, such as an end-to-end suite, keeps its tests in - * `src/`, and the rule follows those. - * - * The rule reports each test that reaches no declared part. Move such a test - * to an ordinary test file, where it stays as supplemental coverage. A file - * whose tests reach no part, and that declares none itself, gets one report - * that asks for the declaration. - * - * Files whose names start with `oracle-`, such as `oracle-replay.test.ts`, - * test the shared oracle machinery, and the rule skips them. - * - * A unit test that happens to use a declared grammar passes the rule. - * The rule cannot see whether a test that reaches a part also compares - * production with hand-written values, or whether a declared part is really - * a check or a model. Reviewers still check those. - * - * An oracle file registers only oracle tests, so it may not call an imported - * helper that registers a test of its own. Such a call gets an - * importedRegistration report: the author registers those tests from an - * ordinary test file. The helper is found through a named, default, or - * namespace import. A helper that registers the body its caller passes does not - * register a test of its own. Its caller's body is the test, and it is judged - * at the call, as a local parameter registration is: the argument at the body - * parameter's position, so a trailing timeout is not the body. A helper may - * wrap the body in a function of its own; when that wrapper uses a part, every - * call's test reaches it. A call to a helper in an oracle-named module is not - * judged here, because that module's tests are linted on their own. An - * `oracle-` machinery module is not linted, so its helpers are judged here as - * an ordinary module's are. - */ - -const declarationHelper = `declareOracle` - -/** - * Every module that an earlier lint parsed, by path, with the mtime and size - * it was read at. Parsing is the costly part, so a module is parsed again only - * when it changes. The reach that a lint computes is never kept between lints, - * so an edit to any module reaches the next lint: see LintSession. - */ -const parsedModules = new Map() - -/** - * The reach state of one lint run: the modules it loaded, the production - * package flags it read, and the reach search that resolves bindings. - */ -class LintSession { - modules = new Map() - published = new Map() - search = new ReachSearch() -} - -/** - * Resolves reach through cycles. A binding or export that is still being - * computed reads as not reaching, which is its least fixed point. A value that - * consulted such an assumption above its own computation is not kept, since the - * assumption may not hold: it is computed again on its next read. A reaching - * value is always kept, because reach only comes from declared parts. - */ -class ReachSearch { - #frames = [] - #active = new Map() - - solve(cache, key, compute) { - const known = cache.get(key) - if (known !== undefined) return known - if (!this.#active.has(cache)) this.#active.set(cache, new Map()) - const active = this.#active.get(cache) - const depth = active.get(key) - if (depth !== undefined) { - const frame = this.#frames.at(-1) - frame.low = Math.min(frame.low, depth) - return false - } - const frame = { depth: this.#frames.length, low: Infinity } - this.#frames.push(frame) - active.set(key, frame.depth) - let reaches - try { - reaches = compute() - } finally { - this.#frames.pop() - active.delete(key) - } - const caller = this.#frames.at(-1) - if (caller !== undefined) caller.low = Math.min(caller.low, frame.low) - if (reaches || frame.low >= frame.depth) cache.set(key, reaches) - return reaches - } -} - -/** - * Whether a binding that a module imports from `source` reaches a part. A - * module that cannot be loaded reaches nothing. - */ -function importedModuleReaches(fromFile, source, name, session) { - const file = resolveTestModule(fromFile, source, session) - const module = file === undefined ? undefined : loadModule(file, session) - return module !== undefined && module.exportReaches(name) -} - -/** Whether a function that a module imports from `source` registers a test. */ -function importedRegisters(fromFile, source, name, session, visiting) { - const file = resolveTestModule(fromFile, source, session) - const module = file === undefined ? undefined : loadModule(file, session) - return module?.registrationShape(name, visiting) -} - -/** - * The shape of an imported helper that a call in an oracle file invokes, or - * undefined. The root is the callee under any curried calls, as in - * `serializerProp(property, [arb])(name, check)`: a named or default import, - * or a member of a namespace import, as in `helpers.registerUnits()`. A helper - * in a module that is linted on its own is judged there. - */ -function importedHelperShape(node, file, sourceCode, session) { - let callee = node.callee - while (callee.type === `CallExpression`) callee = callee.callee - const target = importedTarget(callee, sourceCode.getScope(node)) - if (target === undefined) return undefined - const module = resolveTestModule(file, target.source, session) - if (module === undefined || lintedOnItsOwn(module)) return undefined - return loadModule(module, session)?.registrationShape(target.name) -} - -/** - * The import that an identifier or a namespace member names, as - * `{ source, name }`, or undefined when it names no import. - */ -function importedTarget(node, scope) { - if ( - node.type === `MemberExpression` && - !node.computed && - node.object.type === `Identifier` - ) { - const definition = findVariable(scope, node.object.name)?.defs[0] - return definition?.type === `ImportBinding` && - definition.node.type === `ImportNamespaceSpecifier` - ? { source: definition.parent.source.value, name: node.property.name } - : undefined - } - if (node.type !== `Identifier`) return undefined - const definition = findVariable(scope, node.name)?.defs[0] - return definition?.type === `ImportBinding` - ? importBindingTarget(definition) - : undefined -} - -/** - * The export that an import binding names: the specifier's name, or `default`. - * A namespace import names a whole module, so it gives undefined. - */ -function importBindingTarget(definition) { - const source = definition.parent.source.value - if (definition.node.type === `ImportSpecifier`) { - const { imported } = definition.node - return { source, name: imported.name ?? imported.value } - } - if (definition.node.type === `ImportDefaultSpecifier`) { - return { source, name: `default` } - } - return undefined -} - -/** - * Whether the lint selection lints a module as an oracle file: its name - * contains `oracle`, and it is not an `oracle-` machinery module, which no - * lint reads. - */ -function lintedOnItsOwn(file) { - const name = basename(file) - return name.includes(`oracle`) && !name.startsWith(`oracle-`) -} - -/** - * Whether a helper call reaches a part through the body its caller passes. The - * body is the argument at the position of the helper's body parameter. It - * reaches when it uses a part. A fast-check property (`.prop`) also reaches - * when one of its arbitraries generates values and its body reads the value, - * because then the body is a generated property. A wrapper that the helper - * writes around the body, and that uses a part itself, makes the test reach. - */ -function callerBodyReaches(node, helper, module) { - if (helper.wrapperReaches) return true - const body = curriedArguments(node, helper.level)[helper.index] - if (body === undefined) return false - if (module.nodeReaches(body)) return true - return ( - helper.prop && - isFunction(body) && - generatesValues(node, module) && - module.usesInput(body) - ) -} - -/** - * The arguments of one call in a curried call: level 0 is the call of the - * helper itself, and each later level is the call of the function before it - * returned. - */ -function curriedArguments(node, level) { - const calls = [] - for (let call = node; call.type === `CallExpression`; call = call.callee) { - calls.unshift(call) - } - return calls[level]?.arguments ?? [] -} - -/** - * Whether an arbitrary generates values: a call that is not a constant, or a - * variable whose initializer is one. An identifier is followed to its definition. - */ -function generatedArbitrary(value, module) { - if (value?.type === `Identifier`) { - const definition = findVariable(module.scopeOf(value), value.name)?.defs[0] - return ( - definition?.type === `Variable` && - definition.node.init !== null && - generatedArbitrary(definition.node.init, module) - ) - } - return value?.type === `CallExpression` && !isConstant(value) -} - -/** Whether a curried call's arguments include a non-constant arbitrary. */ -function generatesValues(node, module) { - for ( - let inner = node.callee; - inner.type === `CallExpression`; - inner = inner.callee - ) { - for (const argument of inner.arguments) { - const values = - argument.type === `ArrayExpression` ? argument.elements : [argument] - if (values.some((value) => generatedArbitrary(value, module))) { - return true - } - } - } - return false -} - -/** The test module that a relative import names, or undefined. */ -function resolveTestModule(fromFile, source, session) { - if (!source.startsWith(`.`)) return undefined - const base = resolve(dirname(fromFile), source) - const stem = base.replace(/\.(m?js|tsx?)$/, ``) - // A `.js` specifier names the TypeScript source when one exists, and - // otherwise the JavaScript module itself. - const file = [ - `${stem}.ts`, - `${stem}.tsx`, - ...(/\.m?js$/.test(base) ? [base] : []), - `${base}/index.ts`, - ].find((candidate) => existsSync(candidate)) - // Production declares no oracle part, so the rule does not parse it. - if (file === undefined || isProductionModule(file, session)) return undefined - return file -} - -/** - * Whether a file is shipped source: under `src/` in a published package. A - * private package, such as an end-to-end suite, keeps its tests in `src/`. - */ -function isProductionModule(file, session) { - for (let directory = dirname(file); ; directory = dirname(directory)) { - const manifest = join(directory, `package.json`) - if (existsSync(manifest)) { - if (!session.published.has(directory)) { - const { private: isPrivate } = JSON.parse( - readFileSync(manifest, `utf8`), - ) - session.published.set(directory, isPrivate !== true) - } - return ( - session.published.get(directory) && - relative(directory, file).split(sep)[0] === `src` - ) - } - if (dirname(directory) === directory) return false - } -} - -/** The name of an object property, whether its key is an identifier or a string. */ -function propertyName(property) { - if (property.computed) return stringValue(property.key) - return property.key.name ?? stringValue(property.key) -} - -function inTypeQuery(identifier) { - for (let node = identifier.parent; node; node = node.parent) { - if (node.type === `TSTypeQuery`) return true - } - return false -} - -/** ESLint sets `parent` on the file it lints; a module parsed here needs it too. */ -function setParents(node, parent) { - node.parent = parent - for (const [key, child] of Object.entries(node)) { - if (key === `parent`) continue - for (const item of Array.isArray(child) ? child : [child]) { - if (typeof item?.type === `string`) setParents(item, node) - } - } -} - -/** - * The module that a file names, for this lint. A module that cannot be read or - * does not parse reaches nothing. Its own lint reports the error. - */ -function loadModule(file, session) { - if (!session.modules.has(file)) { - let parsed - try { - parsed = parseModule(file) - } catch { - parsed = undefined - } - const module = - parsed === undefined - ? undefined - : new OracleModule(file, parsed.ast, parsed.scopeManager, session) - session.modules.set(file, module) - } - return session.modules.get(file) -} - -/** The parsed AST of a module, reused while its mtime and size are unchanged. */ -function parseModule(file) { - const { mtimeMs, size } = statSync(file) - const cached = parsedModules.get(file) - if (cached?.mtimeMs === mtimeMs && cached.size === size) return cached - const { ast, scopeManager } = parseForESLint(readFileSync(file, `utf8`), { - filePath: file, - jsx: file.endsWith(`.tsx`), - loc: true, - range: true, - sourceType: `module`, - }) - setParents(ast, null) - const parsed = { mtimeMs, size, ast, scopeManager } - parsedModules.set(file, parsed) - return parsed -} - -class OracleModule { - constructor(file, ast, scopeManager, session) { - this.file = file - this.scopeManager = scopeManager - this.session = session - this.reach = new Map() - // Reach of a binding seen from another module, which ignores parameters. - this.exportedReach = new Map() - // Reach of an exported name, by name. - this.exportReachCache = new Map() - // Resolved references in source order, so a node's references are one - // contiguous range. - this.references = scopeManager.scopes - .flatMap((scope) => scope.references) - // A type annotation runs nothing, so only value references reach. - // `typeof check` in a type names a value, and still runs nothing. - .filter( - (reference) => - reference.resolved !== null && - reference.isValueReference && - !inTypeQuery(reference.identifier), - ) - .sort((a, b) => a.identifier.range[0] - b.identifier.range[0]) - this.resolvedAt = new Map( - this.references.map((reference) => [ - reference.identifier, - reference.resolved, - ]), - ) - // Start offsets of the generated properties in this module, in order. - this.properties = [] - walk(ast, (node) => { - if (this.#isGeneratedProperty(node)) this.properties.push(node.range[0]) - }) - this.properties.sort((a, b) => a - b) - // Names that `typeModel` lists, with the string literal that names each. - this.typeModels = new Map() - this.declared = this.#declaredParts() - this.#resolveTypeModels(ast) - this.exported = new Map() - this.starSources = [] - for (const statement of ast.body) this.#recordExports(statement) - } - - /** The variables that `declareOracle({ check, model })` names. */ - #declaredParts() { - const declared = new Set() - for (const reference of this.references) { - const call = reference.identifier.parent - const definition = reference.resolved.defs[0] - if ( - reference.identifier.name !== declarationHelper || - call.type !== `CallExpression` || - call.callee !== reference.identifier || - definition?.type !== `ImportBinding` || - !isOracleConfigSource(definition.parent.source.value) - ) { - continue - } - for (const property of this.#declarationProperties(call.arguments[0])) { - const value = property.value - const parts = - value?.type === `ArrayExpression` ? value.elements : [value] - if (propertyName(property) === `typeModel`) { - for (const part of parts) { - const name = stringValue(part) - if (name !== undefined) this.typeModels.set(name, part) - } - continue - } - for (const part of parts) { - const variable = - part?.type === `Identifier` ? this.resolvedAt.get(part) : undefined - if (variable !== undefined) declared.add(variable) - } - } - } - return declared - } - - /** - * The properties of a declaration object. A spread of a const object - * literal contributes that object's properties, as the type accepts it. - */ - #declarationProperties(object, seen = new Set()) { - if (object?.type !== `ObjectExpression` || seen.has(object)) return [] - seen.add(object) - return object.properties.flatMap((property) => { - if (property.type !== `SpreadElement`) return [property] - const definition = - property.argument.type === `Identifier` - ? this.resolvedAt.get(property.argument)?.defs[0] - : undefined - return definition?.type === `Variable` && - definition.parent.kind === `const` - ? this.#declarationProperties(definition.node.init, seen) - : [] - }) - } - - #recordExports(statement) { - if (statement.type === `ExportDefaultDeclaration`) { - this.#recordDefaultExport(statement.declaration) - return - } - if (statement.type === `ExportAllDeclaration`) { - if (statement.exported === null) { - this.starSources.push(statement.source.value) - } - return - } - if (statement.type !== `ExportNamedDeclaration`) return - if (statement.declaration !== null) { - // A class declares its name twice: in the module and inside its own - // scope. The module binding is the one a declaration refers to. - const moduleScope = this.#moduleScope() - for (const variable of this.scopeManager.getDeclaredVariables( - statement.declaration, - )) { - if (variable.scope === moduleScope) - this.exported.set(variable.name, { variable }) - } - return - } - for (const specifier of statement.specifiers) { - const exported = specifier.exported.name ?? specifier.exported.value - const local = specifier.local.name ?? specifier.local.value - if (statement.source !== null) { - this.exported.set(exported, { - source: statement.source.value, - imported: local, - }) - } else { - const variable = findVariable(this.#moduleScope(), local) - if (variable !== undefined) this.exported.set(exported, { variable }) - } - } - } - - /** - * `export default` names a module binding, declares a named function or - * class, or exports an expression, which reaches when its code does. - */ - #recordDefaultExport(declaration) { - const moduleScope = this.#moduleScope() - const variable = - declaration.type === `Identifier` - ? findVariable(moduleScope, declaration.name) - : declaration.id - ? this.scopeManager - .getDeclaredVariables(declaration) - .find((declared) => declared.scope === moduleScope) - : undefined - this.exported.set( - `default`, - variable === undefined ? { node: declaration } : { variable }, - ) - } - - #moduleScope() { - return ( - this.scopeManager.scopes.find((scope) => scope.type === `module`) ?? - this.scopeManager.globalScope - ) - } - - get declaresParts() { - return this.declared.size > 0 || this.typeModels.size > 0 - } - - /** - * A type model is a type alias or interface in this module's scope, which - * a type-level test uses as its expected type. Record where each is used, - * and the names that name no such type. - */ - #resolveTypeModels(ast) { - this.typeModelUses = [] - this.unknownTypeModels = [] - if (this.typeModels.size === 0) return - const defined = new Map() - for (const statement of ast.body) { - const declaration = - statement.type === `ExportNamedDeclaration` - ? statement.declaration - : statement - if ( - declaration?.type === `TSTypeAliasDeclaration` || - declaration?.type === `TSInterfaceDeclaration` - ) { - defined.set(declaration.id.name, declaration) - } - } - for (const [name, literal] of this.typeModels) { - if (!defined.has(name)) this.unknownTypeModels.push(literal) - } - // A type built from a model, such as `PublishedValueFor` under - // another name, predicts what the model predicts. - const models = new Set( - [...this.typeModels.keys()].filter((name) => defined.has(name)), - ) - const namesModel = (node) => { - let found = false - walk(node, (child) => { - if ( - child.type === `TSTypeReference` && - child.typeName.type === `Identifier` && - models.has(child.typeName.name) - ) { - found = true - } - }) - return found - } - for (let grew = true; grew;) { - grew = false - for (const [name, declaration] of defined) { - if (!models.has(name) && namesModel(declaration)) { - models.add(name) - grew = true - } - } - } - walk(ast, (node) => { - if ( - node.type === `TSTypeReference` && - node.typeName.type === `Identifier` && - models.has(node.typeName.name) - ) { - this.typeModelUses.push(node.range[0]) - } - }) - this.typeModelUses.sort((a, b) => a - b) - } - - /** - * Whether a binding is a declared part, or uses one. Seen from another - * module (`local` false), a parameter reaches nothing: the importer passes - * its own arguments, so this module's call sites say nothing about it. - */ - variableReaches(variable, local = true) { - if (variable === undefined) return false - if (this.declared.has(variable)) return true - return this.session.search.solve( - local ? this.reach : this.exportedReach, - variable, - () => - variable.defs.some((definition) => - this.#definitionReaches(definition, local), - ) || - this.#assignedValueReaches(variable, local) || - (local && this.#fedByReachingValue(variable, local)), - ) - } - - /** - * A binding that code assigns after its declaration, such as a `let` that a - * hook sets, holds the value assigned to it. It reaches when one does. - */ - #assignedValueReaches(variable, local) { - return variable.references.some( - (reference) => - reference.isWrite() && - !reference.init && - reference.writeExpr != null && - this.nodeReaches(reference.writeExpr, local), - ) - } - - /** - * A collection that code fills with values from a declared part, such as a - * coverage Set that grammar-driven tests add the model's predictions to, - * carries those values. A test that reads it uses the part. - */ - #fedByReachingValue(variable, local) { - return variable.references.some((reference) => { - const member = reference.identifier.parent - const call = member?.parent - return ( - member?.type === `MemberExpression` && - member.object === reference.identifier && - !member.computed && - accumulatorMethods.has(member.property.name) && - call?.type === `CallExpression` && - call.callee === member && - call.arguments.some((argument) => this.nodeReaches(argument, local)) - ) - }) - } - - #definitionReaches(definition, local) { - switch (definition.type) { - case `ImportBinding`: { - // A namespace import reaches through the member that each use names. - const target = importBindingTarget(definition) - return ( - target !== undefined && - this.#importReaches(target.source, target.name) - ) - } - case `FunctionName`: - case `ClassName`: - return this.nodeReaches(definition.node, local) - case `Variable`: { - const loop = definition.parent.parent - if (loop?.type === `ForOfStatement` && loop.left === definition.parent) - return this.#everyElementReaches(loop.right, local) - return ( - definition.node.init !== null && - this.nodeReaches(definition.node.init, local) - ) - } - case `Parameter`: - return local && this.#parameterReaches(definition) - default: - return false - } - } - - /** - * A parameter reaches a part when every call of its function passes an - * argument that reaches one, as when a helper registers the property that - * its caller passes in. A function that the file never calls by name - * passes nothing the rule can see. - */ - #parameterReaches(definition) { - const index = definition.node.params.indexOf(definition.name) - const calls = this.#callsOf(definition) - return ( - index >= 0 && - calls.length > 0 && - calls.every((call) => { - const argument = call.arguments[index] - return argument !== undefined && this.nodeReaches(argument) - }) - ) - } - - /** The calls, by name, of the function that declares a parameter. */ - #callsOf(definition) { - const fn = definition.node - const binding = - fn.type === `FunctionDeclaration` - ? fn.id - : fn.parent.type === `VariableDeclarator` - ? fn.parent.id - : undefined - if (binding?.type !== `Identifier`) return [] - const variable = this.scopeManager - .getDeclaredVariables(fn.type === `FunctionDeclaration` ? fn : fn.parent) - .find((declared) => declared.name === binding.name) - return (variable?.references ?? []) - .map((reference) => reference.identifier.parent) - .filter( - (call) => - call.type === `CallExpression` && - call.callee.type === `Identifier` && - call.callee.name === binding.name, - ) - } - - /** - * A `for...of` loop variable takes each element in turn, and a registration - * in the loop registers one test per element, so every element must reach. - * The rule reads an array written in the loop or bound to a const name. - */ - #everyElementReaches(iterable, local) { - let array = iterable - if (array.type === `Identifier`) { - const definition = this.resolvedAt.get(array)?.defs[0] - array = - definition?.type === `Variable` && definition.parent.kind === `const` - ? definition.node.init - : undefined - } - return ( - array?.type === `ArrayExpression` && - array.elements.length > 0 && - array.elements.every( - (element) => - element !== null && - element.type !== `SpreadElement` && - this.nodeReaches(element, local), - ) - ) - } - - #importReaches(source, name) { - return importedModuleReaches(this.file, source, name, this.session) - } - - /** Whether an export of this module is a declared part, or uses one. */ - exportReaches(name) { - return this.session.search.solve(this.exportReachCache, name, () => { - const entry = this.exported.get(name) - if (entry?.variable !== undefined) - return this.variableReaches(entry.variable, false) - if (entry?.node !== undefined) return this.nodeReaches(entry.node, false) - if (entry !== undefined) - return this.#importReaches(entry.source, entry.imported) - // `export *` does not carry a default export. - if (name === `default`) return false - return this.starSources.some((source) => - this.#importReaches(source, name), - ) - }) - } - - /** - * How an export of this module registers tests, or undefined when it - * registers none. A function registers the body that it writes ('own') or - * the body that its caller passes ('parameter'). A cycle of re-exports - * registers nothing. - */ - registrationShape(name, visiting = new Set()) { - const key = `${this.file}\0${name}` - if (visiting.has(key)) return undefined - visiting.add(key) - const entry = this.exported.get(name) - if (entry?.variable !== undefined) { - const definition = entry.variable.defs[0] - const fn = - definition?.type === `FunctionName` - ? definition.node - : definition?.type === `Variable` && isFunction(definition.node.init) - ? definition.node.init - : undefined - return fn === undefined ? undefined : this.#bodyShape(fn) - } - if (entry?.node !== undefined) { - return isFunction(entry.node) ? this.#bodyShape(entry.node) : undefined - } - if (entry !== undefined) { - return importedRegisters( - this.file, - entry.source, - entry.imported, - this.session, - visiting, - ) - } - if (name === `default`) return undefined - for (const source of this.starSources) { - const shape = importedRegisters( - this.file, - source, - name, - this.session, - visiting, - ) - if (shape !== undefined) return shape - } - return undefined - } - - /** - * The shape of the registrations in a function's body. A registration - * registers the caller's body when its body is a parameter of the function, - * or of a function that it returns, or when its body is a wrapper written - * here that calls such a parameter. The shape records where the caller - * passes that body, and whether the wrapper uses a part itself. Any other - * registration registers a body that the function writes, which wins. - */ - #bodyShape(fn) { - const sourceCode = { getScope: (node) => this.scopeOf(node) } - let shape - walk(fn, (node) => { - if (node.type !== `CallExpression`) return - const test = registeredTest(node, sourceCode) - if (test === undefined) return - const position = this.#passedBody(test.body, fn, sourceCode) - if (position === undefined) shape = { kind: `own` } - else if (shape?.kind !== `own`) { - shape = { - kind: `parameter`, - ...position, - prop: calleeCall(node.callee, `prop`) !== undefined, - // Seen from the caller, the helper's parameters reach nothing. - wrapperReaches: - isFunction(test.body) && this.nodeReaches(test.body, false), - } - } - }) - return shape - } - - /** - * Where a registration's body comes from, when its caller passes it: the - * body is a parameter of `fn` or of a function inside `fn`, or a wrapper - * that calls one. The position is `{ index, level }`: the parameter's index, - * and the number of functions from `fn` to the one that declares it. - */ - #passedBody(body, fn, sourceCode) { - const positionOf = (identifier) => { - const definition = findVariable( - sourceCode.getScope(identifier), - identifier.name, - )?.defs[0] - if (definition?.type !== `Parameter`) return undefined - const owner = definition.node - let level = 0 - for (let node = owner; node !== fn; node = node.parent) { - // A parameter of the wrapper itself, or of a function outside `fn`. - if (node === null || node === body) return undefined - if (isFunction(node)) level++ - } - return { index: owner.params.indexOf(definition.name), level } - } - if (body.type === `Identifier`) return positionOf(body) - if (!isFunction(body)) return undefined - let position - walk(body, (node) => { - if ( - position === undefined && - node.type === `CallExpression` && - node.callee.type === `Identifier` - ) { - position = positionOf(node.callee) - } - }) - return position - } - - /** The innermost scope of a node, as ESLint's `getScope` finds it. */ - scopeOf(node) { - for (let current = node; current; current = current.parent) { - const scope = this.scopeManager.acquire(current, true) - if (scope) return scope - } - return this.scopeManager.globalScope - } - - /** - * A generated property: `fc.property(...)` or `fc.asyncProperty(...)` from - * fast-check, or a `.prop([...])` registration. Its predicate states a law - * over inputs that nobody wrote down, so it is its own check. A property - * whose arbitraries are all constants checks one written input, so it does - * not count. - */ - #isGeneratedProperty(node) { - if (node.type !== `CallExpression`) return false - const { callee } = node - if (callee.type !== `MemberExpression` || callee.computed) return false - const name = callee.property.name - if (name !== `property` && name !== `asyncProperty`) return false - if (!this.#isFastCheck(callee.object)) return false - const predicate = node.arguments.at(-1) - return ( - node.arguments - .slice(0, -1) - .some((value) => !this.#constantArbitrary(value)) && - this.#predicateUsesInput(predicate) - ) - } - - /** - * `fc.constant(x)` or `fc.constantFrom(...)`, written here or bound to a - * const name. An arbitrary from elsewhere cannot be seen, so it is not one. - */ - #constantArbitrary(value, seen = new Set()) { - if (value?.type !== `Identifier`) return isConstant(value) - const definition = this.resolvedAt.get(value)?.defs[0] - if ( - definition?.type !== `Variable` || - definition.parent.kind !== `const` || - seen.has(definition.node) - ) { - return false - } - seen.add(definition.node) - return this.#constantArbitrary(definition.node.init, seen) - } - - /** - * Whether a property's predicate reads its input. A named predicate is read - * at its definition. One passed in as a parameter cannot be seen here, so - * it counts. - */ - #predicateUsesInput(predicate) { - if (predicate?.type !== `Identifier`) return this.usesInput(predicate) - const definition = this.resolvedAt.get(predicate)?.defs[0] - if (definition?.type === `FunctionName`) - return this.usesInput(definition.node) - if (definition?.type === `Variable`) - return this.usesInput(definition.node.init) - if (definition?.type === `ImportBinding`) - return this.#importedFunctionUsesInput(definition) ?? true - return true - } - - /** - * Whether an imported function reads a parameter, read at its definition. - * Undefined when the rule cannot load the function, as for a package. - */ - #importedFunctionUsesInput(definition) { - const target = importBindingTarget(definition) - const file = - target === undefined - ? undefined - : resolveTestModule(this.file, target.source, this.session) - const module = - file === undefined ? undefined : loadModule(file, this.session) - return module?.exportedFunctionUsesInput(target.name) - } - - /** Whether an export is a function that reads a parameter; undefined if it is not one. */ - exportedFunctionUsesInput(name) { - const definition = this.exported.get(name)?.variable?.defs[0] - if (definition?.type === `FunctionName`) - return this.usesInput(definition.node) - if (definition?.type === `Variable`) - return this.usesInput(definition.node.init) - return undefined - } - - /** A function that reads at least one of its parameters. */ - usesInput(fn) { - if (!isFunction(fn) && fn?.type !== `FunctionDeclaration`) return false - return this.scopeManager - .getDeclaredVariables(fn) - .some( - (variable) => - variable.defs[0]?.type === `Parameter` && - variable.references.length > 0, - ) - } - - /** - * Whether a `.prop([...])` registration runs a generated property: some - * arbitrary is not a constant, and the test reads its input. A named test - * is checked at its definition, and a test passed in as a parameter at - * every call of its helper. - */ - propRegistrationGenerates(registration, body, sourceCode) { - const prop = calleeCall(registration.callee, `prop`) - if (prop === undefined) return false - const [arbitraries] = prop.arguments - const values = - arbitraries?.type === `ArrayExpression` - ? arbitraries.elements - : arbitraries?.type === `ObjectExpression` - ? arbitraries.properties.map((property) => property.value) - : undefined - // Arbitraries that a caller passes in are not visible here, so only - // written-out constants disqualify the registration. - if (values?.every((value) => this.#constantArbitrary(value))) return false - if (isFunction(body)) return this.usesInput(body) - const definition = findVariable(sourceCode.getScope(body), body.name) - ?.defs[0] - // A named predicate: a function declaration or a const bound to one. - if (definition?.type === `FunctionName`) - return this.usesInput(definition.node) - if (definition?.type === `Variable`) - return this.usesInput(definition.node.init) - if (definition?.type === `ImportBinding`) - return this.#importedFunctionUsesInput(definition) ?? true - if (definition?.type !== `Parameter`) return false - const calls = this.#callsOf(definition) - return ( - calls.length > 0 && - calls.every((call) => { - const index = definition.node.params.indexOf(definition.name) - return this.usesInput(call.arguments[index]) - }) - ) - } - - #isFastCheck(node) { - const definition = - node.type === `Identifier` - ? this.resolvedAt.get(node)?.defs[0] - : undefined - return ( - definition?.type === `ImportBinding` && - fastCheckModules.has(definition.parent.source.value) - ) - } - - /** Whether code uses a declared part, directly or through a binding. */ - nodeReaches(node, local = true) { - // Code that runs a generated property that it contains. - if (containsOffset(this.properties, node)) return true - // A type-level test reaches a type model by naming it in a type. - if (containsOffset(this.typeModelUses, node)) return true - const [start, end] = node.range - let low = 0 - let high = this.references.length - while (low < high) { - const middle = (low + high) >> 1 - if (this.references[middle].identifier.range[0] < start) low = middle + 1 - else high = middle - } - for (let index = low; index < this.references.length; index++) { - const reference = this.references[index] - if (reference.identifier.range[0] >= end) break - if (this.#referenceReaches(reference, local)) return true - } - return false - } - - /** A use of a namespace member, such as `model.step`, reaches through that export. */ - #referenceReaches(reference, local) { - const member = reference.identifier.parent - const definition = reference.resolved.defs[0] - if ( - definition?.type === `ImportBinding` && - definition.node.type === `ImportNamespaceSpecifier` && - member?.type === `MemberExpression` && - member.object === reference.identifier && - !member.computed - ) { - return this.#importReaches( - definition.parent.source.value, - member.property.name, - ) - } - return this.variableReaches(reference.resolved, local) - } -} - -const fastCheckModules = new Set([`fast-check`, `@fast-check/vitest`]) - -// Methods that put a value into a collection. -const accumulatorMethods = new Set([`add`, `set`, `push`, `unshift`]) - -/** `fc.constant(x)` or `fc.constantFrom(...)`: an arbitrary of written values. */ -function isConstant(node) { - return ( - node?.type === `CallExpression` && - node.callee.type === `MemberExpression` && - !node.callee.computed && - [`constant`, `constantFrom`].includes(node.callee.property.name) - ) -} - -/** The value of a string literal or of a template with no substitutions. */ -function stringValue(node) { - if (node?.type === `Literal` && typeof node.value === `string`) - return node.value - if (node?.type === `TemplateLiteral` && node.expressions.length === 0) - return node.quasis[0].value.cooked - return undefined -} - -/** Whether a sorted list of start offsets has one inside a node. */ -function containsOffset(offsets, node) { - const [start, end] = node.range - let low = 0 - let high = offsets.length - while (low < high) { - const middle = (low + high) >> 1 - if (offsets[middle] < start) low = middle + 1 - else high = middle - } - return low < offsets.length && offsets[low] < end -} - -function walk(node, visit) { - visit(node) - for (const [key, child] of Object.entries(node)) { - if (key === `parent`) continue - for (const item of Array.isArray(child) ? child : [child]) { - if (typeof item?.type === `string`) walk(item, visit) - } - } -} - -/** The `.(...)` call in a registration's callee chain, if any. */ -function calleeCall(callee, name) { - for (let node = callee; node;) { - if ( - node.type === `CallExpression` && - node.callee.type === `MemberExpression` && - !node.callee.computed && - node.callee.property.name === name - ) { - return node - } - node = - node.type === `CallExpression` - ? node.callee - : node.type === `MemberExpression` - ? node.object - : undefined - } - return undefined -} - -/** Whether a function written in an `.each(...)` table uses a declared part. */ -function tableFunctionReaches(module, table) { - let reaches = false - for (const row of table.arguments) { - walk(row, (node) => { - if (!reaches && isFunction(node) && module.nodeReaches(node)) { - reaches = true - } - }) - } - return reaches -} - -function isPlaceholder(body) { - return ( - isFunction(body) && - body.body.type === `BlockStatement` && - body.body.body.length === 0 - ) -} - -/** - * The test module that a relative specifier names in `fromFile`, as this rule - * resolves it: undefined for production source, a package, or a missing file. - * The lint selection in scripts/agent-lint-feedback.mjs follows these edges, so - * an edit reaches the oracle files that this rule reads. - */ -const selectionSession = new LintSession() -export function resolveImportedTestModule(fromFile, specifier) { - return resolveTestModule(fromFile, specifier, selectionSession) -} - -export default { - meta: { - type: `problem`, - docs: { - description: `Report tests in oracle files that reach no declared check or model.`, - }, - schema: [], - messages: { - unitTest: `This test reaches no part that this file declares with declareOracle, so its expected result has no independent source. A unit test belongs in an ordinary test file: move it there. A fixed history runs through the declared check, a model pin uses the declared model, and a grammar calibration uses the declared grammar.`, - unknownTypeModel: `This typeModel name is not a type alias or interface in this file. A type model is the expected type that type-level tests compare with, so it must be declared here.`, - importedRegistration: `This oracle file calls a helper that registers tests. An oracle file registers only oracle tests, so register those tests from an ordinary test file and keep this file to its checks and model.`, - undeclared: `This oracle file registers tests and declares no oracle part. Name its check, model, and grammar with \`declareOracle({ check: [...], model: [...], grammar: [...] })\` from oracle-declaration. Then move each test that reaches none of them to an ordinary test file.`, - }, - }, - create(context) { - // `oracle-config`, `oracle-reach`, and the other `oracle-` modules are the - // shared oracle machinery. Their tests check that machinery, not a law. - if (basename(context.filename).startsWith(`oracle-`)) return {} - const { sourceCode } = context - const session = new LintSession() - const module = new OracleModule( - context.filename, - sourceCode.ast, - sourceCode.scopeManager, - session, - ) - const unreached = [] - let anyReached = false - return { - CallExpression(node) { - // A curried callee is not a call of its own: the outermost call is the - // registration. - const outermost = !( - node.parent.type === `CallExpression` && node.parent.callee === node - ) - const helper = outermost - ? importedHelperShape(node, context.filename, sourceCode, session) - : undefined - if (helper?.kind === `own`) { - context.report({ node, messageId: `importedRegistration` }) - } - if (helper?.kind === `parameter`) { - // The helper registers the body its caller passes, so the caller's - // body is the test, judged here as a local parameter registration is. - if (callerBodyReaches(node, helper, module)) anyReached = true - else unreached.push(node) - return - } - const test = registeredTest(node, sourceCode) - if (test === undefined || isPlaceholder(test.body)) return - // An `.each(table)` row can carry a function that the test body calls, - // such as a hostile observation each check must reject. A function in - // the table that uses a declared part makes the test use it. Plain - // data, such as the grammar's cells, does not: the body must use them. - const table = - calleeCall(node.callee, `each`) ?? calleeCall(node.callee, `for`) - const reaches = - module.propRegistrationGenerates(node, test.body, sourceCode) || - (table !== undefined && tableFunctionReaches(module, table)) || - (test.body.type === `Identifier` - ? module.variableReaches( - findVariable(sourceCode.getScope(test.body), test.body.name), - ) - : module.nodeReaches(test.body)) - if (reaches) anyReached = true - else unreached.push(node) - }, - 'Program:exit'() { - for (const node of module.unknownTypeModels) { - context.report({ node, messageId: `unknownTypeModel` }) - } - // A file that relies on a companion module's declaration declares - // nothing itself. It is undeclared only when no test reaches a part. - if (!module.declaresParts && !anyReached) { - if (unreached.length > 0) { - context.report({ node: unreached[0], messageId: `undeclared` }) - } - return - } - for (const node of unreached) { - context.report({ node, messageId: `unitTest` }) - } - }, - } - }, -} diff --git a/scripts/eslint-plugin-oracle/vitest.config.mjs b/scripts/eslint-plugin-oracle/vitest.config.mjs deleted file mode 100644 index d34dfc0940..0000000000 --- a/scripts/eslint-plugin-oracle/vitest.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { defineConfig } from 'vitest/config' - -// The lint-rule suites run in Node. They need no coverage and no package -// setup, so this config stays separate from the packages' configs. -export default defineConfig({ - test: { - root: dirname(fileURLToPath(import.meta.url)), - include: [`*.test.js`], - environment: `node`, - coverage: { enabled: false }, - }, -}) diff --git a/scripts/lint-scope-oracle.property.test.js b/scripts/lint-scope-oracle.property.test.js index 292aa3926e..0c701dcb25 100644 --- a/scripts/lint-scope-oracle.property.test.js +++ b/scripts/lint-scope-oracle.property.test.js @@ -4,9 +4,15 @@ * the pre-commit hook. An oracle file's result depends on the test modules that * it reaches through relative imports and re-exports: its companions, support * modules, drivers, and other oracle files. The rule does not read production - * source, so an edit to production code selects nothing. A change to the - * rule's own sources changes every oracle result, so it selects every oracle - * file. An oracle file is any file in the working tree whose name contains + * source, so an edit to production code selects nothing. The rules come from + * the @tanstack/eslint-plugin-oracles dependency, which pnpm-lock.yaml pins, + * under the settings in scripts/oracle-lint-settings.mjs. A change to the lint + * configuration changes every oracle result, so it selects every oracle file: + * any edit to the settings module, and a lockfile whose entries for the plugin + * (its importer entry, its resolution, and its snapshot with its resolved + * dependencies) differ from the merge-base's. Any other lockfile edit, such as + * an unrelated dependency bump, installs the same rule, so it selects nothing, + * as a production edit does. An oracle file is any file in the working tree whose name contains * `oracle`, in TypeScript or JavaScript, tracked or new and untracked: an * agent's new oracle file is linted like any other. * @@ -14,8 +20,8 @@ * run the script of the checkout where the session started, while the agent * works in a worktree nested under that checkout's `.worktrees/`. The hook * input's `cwd` names the agent's checkout, so the hooks select and lint that - * checkout's files, with that checkout's own copy of the script and the rule, - * as CI does for its branch. A directory under `.worktrees/` that is not a + * checkout's files, with that checkout's own copy of the script, its settings, + * and its installed plugin, as CI does for its branch. A directory under `.worktrees/` that is not a * checkout, such as a scratch copy, is never one of the launch checkout's * changed files. * @@ -27,10 +33,13 @@ * worktree's branch, a plain directory under `.worktrees/` produces no report, * and `pnpm lint:changed` in a worktree lints that worktree. AGENTS.md "Name oracle * files for discovery" defines an oracle file by its name. The rule's header - * contract (scripts/eslint-plugin-oracle/rules/no-unit-test.js) states that the + * contract (@tanstack/eslint-plugin-oracles/rules/no-unit-test) states that the * rule follows relative imports of test modules and does not follow production - * source. The rule-source case follows from the same reports: the oracle - * results are a function of the rule's code. + * source. The configuration case follows from the same reports: the oracle + * results are a function of the installed rule's code and its settings, so a + * lockfile edit that leaves the plugin's resolved entries as they were cannot + * change one (round-1 review of PR #2378, finding B1-10, which asked that an + * unrelated dependency bump stop re-linting every oracle file). * * Limits. The grammar draws up to three oracle files, two or three support * modules, one driver, and one production module, with relative imports and @@ -48,7 +57,17 @@ * one scratch directory. The hooks always pass `cwd`, and the hook process runs * in the launch checkout: a hook input without `cwd` is not generated, and * neither is a worktree outside the launch checkout or one whose branch has no - * copy of the script. The repository's own `.gitignore` is copied into each + * copy of the script. A plugin change is a lockfile edit together with the + * session checkout's own installed copy of the plugin, as an install leaves + * them; it changes one part of the plugin's entries: its version (all three + * entries), its integrity, or one resolved dependency of its snapshot. An + * unrelated lockfile edit bumps another package that nothing depends on. A + * change deeper in the plugin's dependency tree, which leaves its own entries + * alone, is not drawn, and the script would not select it. The lockfile is a + * hand-written pnpm v9 file, not one pnpm wrote: small, or padded past the + * 1 MiB default output buffer of a child process with packages nothing uses. + * The base commit may lack it, and the branch then adds it. A settings edit changes + * no report. The repository's own `.gitignore` is copied into each * temporary repository, so its rules are part of what is tested. * * Structure: @@ -61,16 +80,19 @@ * named scope, and its edit-hook and stop-hook forms. A crashed CLI is a * failure, not an empty selection. * 4. The comparison checks that each observed selection equals the prediction, - * and that every report came from the session checkout's own rule. + * and that every report came from the session checkout's own installed + * rule. * 5. Reach counts the graph states that the campaign enters, and the * campaigns run the same predicate with a fixed seed and a random seed. */ import { execFileSync, spawnSync } from 'node:child_process' import { cpSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, rmSync, symlinkSync, @@ -84,8 +106,13 @@ import fc from 'fast-check' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { declareOracle } from '../packages/db/tests/oracle-declaration.js' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleReplayTest, + oracleRouteOf, oracleRuns, + withOracleFixedHistories, } from '../packages/db/tests/oracle-config.js' import { createOracleReach } from '../packages/db/tests/oracle-reach.js' @@ -95,7 +122,24 @@ const BASE_RUNS = 24 const PRODUCTION = `src/prod.ts` const repoRoot = fileURLToPath(new URL(`../`, import.meta.url)) const APP = `packages/app` -const RULE = `scripts/eslint-plugin-oracle/rules/no-unit-test.js` +// The lint configuration: the lockfile, which pins the plugin, and the +// settings module. Editing the settings, or the plugin's entries in the +// lockfile, selects every oracle file. +const LOCKFILE = `pnpm-lock.yaml` +const SETTINGS = `scripts/oracle-lint-settings.mjs` +const CONFIGURATION = [LOCKFILE, SETTINGS] +// The configuration edits a branch can make: one part of the plugin's +// lockfile entries, with a new installed plugin (its version in all three +// entries, its integrity, or one resolved dependency of its snapshot); +// another package's lockfile entries; or the settings module. +const PLUGIN_EDITS = [`plugin-version`, `plugin-integrity`, `plugin-dependency`] +const LOCKFILE_EDIT = `lockfile` +const SETTINGS_EDIT = `settings` +const isPluginEdit = (edit) => PLUGIN_EDITS.includes(edit) +const fileOfEdit = (edit) => (edit === SETTINGS_EDIT ? SETTINGS : LOCKFILE) +// The installed plugin's rule that reports the generated files. +const PLUGIN = join(`node_modules`, `@tanstack`, `eslint-plugin-oracles`) +const RULE_SOURCE = join(PLUGIN, `src`, `rules`, `no-unit-test.js`) // -- 1. Grammar --------------------------------------------------------------- // @@ -106,7 +150,7 @@ const RULE = `scripts/eslint-plugin-oracle/rules/no-unit-test.js` // is a legal edge. A star re-export is an import whose names the module passes // on. An edit adds a line to a module, so its text differs from the base // commit. An untracked oracle file exists only in the working tree, so nothing -// imports it. The edit hook names one module, or the rule's source. +// imports it. The edit hook names one module, or a lint configuration file. const moduleEdit = fc.boolean() @@ -123,81 +167,145 @@ const singleOrManyTargets = fc.oneof( }, ) -const programArbitrary = fc - .record({ - // Random draws reach three shapes too rarely, so a share of the programs - // take each of them. See the shape functions below. - shape: fc.oneof( - { weight: 2, arbitrary: fc.constant(`free`) }, - { weight: 2, arbitrary: fc.constant(`re-export chain`) }, - { weight: 2, arbitrary: fc.constant(`oracle chain`) }, - { weight: 1, arbitrary: fc.constant(`untracked dependent`) }, - ), - oracles: fc.array( - fc.record({ - edited: moduleEdit, - targets: singleOrManyTargets, - style: fc.nat({ max: 1 }), - extension: fc.oneof( - { weight: 2, arbitrary: fc.constant(`ts`) }, - { weight: 1, arbitrary: fc.constant(`js`) }, - ), - untracked: fc.oneof( - { weight: 3, arbitrary: fc.constant(false) }, - { weight: 1, arbitrary: fc.constant(true) }, - ), - }), - { minLength: 1, maxLength: 3 }, - ), - // Two supports or more let a chain form, and a single target keeps a - // re-export on the only route to an edited module. - supports: fc.array( - fc.record({ - edited: moduleEdit, - targets: singleOrManyTargets, - reexport: fc.array( - fc.oneof( - { weight: 3, arbitrary: fc.constant(true) }, - { weight: 1, arbitrary: fc.constant(false) }, - ), - { minLength: 1, maxLength: 2 }, - ), - }), - { minLength: 2, maxLength: 3 }, - ), - driver: fc.record({ - present: fc.boolean(), +const skeletonArbitrary = fc.record({ + // Random draws reach three shapes too rarely, so a share of the programs + // take each of them. See the shape functions below. + shape: fc.oneof( + { weight: 2, arbitrary: fc.constant(`free`) }, + { weight: 2, arbitrary: fc.constant(`re-export chain`) }, + { weight: 2, arbitrary: fc.constant(`oracle chain`) }, + { weight: 1, arbitrary: fc.constant(`untracked dependent`) }, + ), + oracles: fc.array( + fc.record({ edited: moduleEdit, - targets: fc.array(fc.nat({ max: 9 }), { maxLength: 2 }), + targets: singleOrManyTargets, + style: fc.nat({ max: 1 }), + extension: fc.oneof( + { weight: 2, arbitrary: fc.constant(`ts`) }, + { weight: 1, arbitrary: fc.constant(`js`) }, + ), + untracked: fc.oneof( + { weight: 3, arbitrary: fc.constant(false) }, + { weight: 1, arbitrary: fc.constant(true) }, + ), + }), + { minLength: 1, maxLength: 3 }, + ), + // Two supports or more let a chain form, and a single target keeps a + // re-export on the only route to an edited module. + supports: fc.array( + fc.record({ + edited: moduleEdit, + targets: singleOrManyTargets, + reexport: fc.array( + fc.oneof( + { weight: 3, arbitrary: fc.constant(true) }, + { weight: 1, arbitrary: fc.constant(false) }, + ), + { minLength: 1, maxLength: 2 }, + ), }), - production: fc.record({ edited: moduleEdit }), - ruleEdited: fc.oneof( - { weight: 3, arbitrary: fc.constant(false) }, - { weight: 1, arbitrary: fc.constant(true) }, + { minLength: 2, maxLength: 3 }, + ), + driver: fc.record({ + present: fc.boolean(), + edited: moduleEdit, + targets: fc.array(fc.nat({ max: 9 }), { maxLength: 2 }), + }), + production: fc.record({ edited: moduleEdit }), + // The lint configuration edit that the branch makes, if any. + ruleEdited: fc.oneof( + { weight: 5, arbitrary: fc.constant(false) }, + ...PLUGIN_EDITS.map((edit) => ({ + weight: 1, + arbitrary: fc.constant(edit), + })), + { weight: 1, arbitrary: fc.constant(LOCKFILE_EDIT) }, + { weight: 1, arbitrary: fc.constant(SETTINGS_EDIT) }, + ), + // The edit hook names a module of this kind, or a configuration file. + hookKind: fc.oneof( + { weight: 2, arbitrary: fc.constant(`oracle`) }, + { weight: 2, arbitrary: fc.constant(`support`) }, + { weight: 2, arbitrary: fc.constant(`driver`) }, + { weight: 1, arbitrary: fc.constant(`production`) }, + { weight: 2, arbitrary: fc.constant(`rule`) }, + ), + hookIndex: fc.nat({ max: 9 }), + // The checkout that the session works in: the one where it started, or a + // worktree nested in it. + session: fc.constantFrom(`launch`, `worktree`), + // A plain directory under the launch checkout's `.worktrees/`, holding an + // oracle-named file that the rule reports. + scratch: fc.boolean(), + // The lockfile: small, or larger than the 1 MiB that a child process's + // output buffer holds by default; and whether the base commit has one. A + // missing base lockfile is one the branch adds. Drawn last, so the fields + // above keep their draws. + lockfile: fc.record({ + size: fc.oneof( + { weight: 3, arbitrary: fc.constant(`small`) }, + { weight: 1, arbitrary: fc.constant(`large`) }, ), - // The edit hook names a module of this kind, or the rule's source. - hookKind: fc.oneof( - { weight: 2, arbitrary: fc.constant(`oracle`) }, - { weight: 2, arbitrary: fc.constant(`support`) }, - { weight: 2, arbitrary: fc.constant(`driver`) }, - { weight: 1, arbitrary: fc.constant(`production`) }, - { weight: 2, arbitrary: fc.constant(`rule`) }, + base: fc.oneof( + { weight: 4, arbitrary: fc.constant(`present`) }, + { weight: 1, arbitrary: fc.constant(`missing`) }, ), - hookIndex: fc.nat({ max: 9 }), - // The checkout that the session works in: the one where it started, or a - // worktree nested in it. - session: fc.constantFrom(`launch`, `worktree`), - // A plain directory under the launch checkout's `.worktrees/`, holding an - // oracle-named file that the rule reports. - scratch: fc.boolean(), - }) - .map(({ shape, ...skeleton }) => { - const drawn = - skeleton.hookKind === `driver` - ? { ...skeleton, driver: { ...skeleton.driver, present: true } } - : skeleton - return buildProgram(shapes[shape](drawn)) + }), +}) + +function programOf({ shape, ...skeleton }) { + const drawn = + skeleton.hookKind === `driver` + ? { ...skeleton, driver: { ...skeleton.driver, present: true } } + : skeleton + return buildProgram(shapes[shape](drawn)) +} + +const programArbitrary = skeletonArbitrary.map(programOf) + +/** + * Constructed histories. Each configuration edit, an edit hook that names the + * lockfile while the plugin's entries stay as they were, an unrelated edit of + * a large lockfile, and a base commit without a lockfile, is a declared state + * that 24 random draws can miss. So every campaign also runs, before its + * random histories, one generated skeleton per state with the one field that + * enters it fixed. The skeletons are drawn from the campaign's seed, so they + * are members of the grammar and vary with the seed. + */ +const constructions = [ + ...[...PLUGIN_EDITS, LOCKFILE_EDIT, SETTINGS_EDIT].map( + (edit) => (skeleton) => ({ ...skeleton, ruleEdited: edit }), + ), + (skeleton) => ({ + ...skeleton, + shape: `free`, + ruleEdited: false, + hookKind: `rule`, + hookIndex: CONFIGURATION.indexOf(LOCKFILE), + lockfile: { ...skeleton.lockfile, base: `present` }, + }), + (skeleton) => ({ + ...skeleton, + ruleEdited: LOCKFILE_EDIT, + lockfile: { size: `large`, base: `present` }, + }), + (skeleton) => ({ + ...skeleton, + lockfile: { ...skeleton.lockfile, base: `missing` }, + }), +] + +function constructedPrograms(seed) { + const skeletons = fc.sample(skeletonArbitrary, { + seed, + numRuns: constructions.length, }) + return constructions.map((construct, index) => [ + programOf(construct(skeletons[index])), + ]) +} const shapes = { free: (skeleton) => skeleton, @@ -378,7 +486,7 @@ function buildProgram(skeleton) { const hookTarget = skeleton.hookName ?? (ofKind.length === 0 - ? RULE + ? CONFIGURATION[skeleton.hookIndex % CONFIGURATION.length] : ofKind[skeleton.hookIndex % ofKind.length].name) return { oracles, @@ -388,6 +496,7 @@ function buildProgram(skeleton) { ruleEdited: skeleton.ruleEdited, session: skeleton.session, scratch: skeleton.scratch, + lockfile: skeleton.lockfile, edges, reexports, modules, @@ -397,7 +506,8 @@ function buildProgram(skeleton) { /** * The files that each scope treats as edited. The changed scope sees every - * edited tracked module and every untracked file. The named scope, which the + * edited tracked module and every untracked file, including a lockfile that + * the base commit lacks. The named scope, which the * pre-commit hook runs, sees the files it is given: the edited tracked modules. * It runs only when there is one: the hook never runs it without files. * The edit hook sees the one file that the hook input names. @@ -409,13 +519,14 @@ function editedByScope(program) { const untracked = program.modules .filter((module) => module.untracked) .map((module) => module.name) - const rule = program.ruleEdited ? [RULE] : [] + const rule = program.ruleEdited ? [fileOfEdit(program.ruleEdited)] : [] + const added = program.lockfile.base === `missing` ? [LOCKFILE] : [] return { - changed: [...tracked, ...untracked, ...rule], + changed: [...tracked, ...untracked, ...added, ...rule], named: [...tracked, ...rule], hook: [program.hookTarget], // The stop hook lints the changed files of the session's checkout. - stop: [...tracked, ...untracked, ...rule], + stop: [...tracked, ...untracked, ...added, ...rule], } } @@ -426,11 +537,21 @@ function editedByScope(program) { // an edge into an affected module. Production is never affected, because the // rule never reads it, so an edge into production carries nothing. The oracle // files in the affected set are the ones to lint. When the edited files -// include the rule's own source, every oracle file must be linted. +// include the settings module, or the lockfile of a branch that changed the +// plugin's entries, every oracle file must be linted. A lockfile that the +// branch adds, where the base commit had none, changes them too: the base has +// no plugin entries. The model knows which edit the branch drew; it does not +// read the lockfile, so its size cannot matter to it. A lockfile that the +// branch left alone, or edited only for another package, is like production: +// no test module imports it, so it affects nothing. /** The oracle files that a lint of `edited` must cover, as sorted basenames. */ function predictSelected(program, edited) { - if (edited.includes(RULE)) + if ( + edited.includes(SETTINGS) || + (edited.includes(LOCKFILE) && + (isPluginEdit(program.ruleEdited) || program.lockfile.base === `missing`)) + ) return program.oracles.map((oracle) => oracle.name).sort() const affected = new Set(edited.filter((name) => name !== PRODUCTION)) let grew = true @@ -450,15 +571,16 @@ function predictSelected(program, edited) { .sort() } -// Each checkout is linted by its own copy of the rule. The driver marks the -// message of the rule copy that a branch edits, and only that copy, so a report -// carries the mark exactly when the session checkout's branch edited the rule. -// The launch checkout's copy is unmarked in a worktree session, so a report -// that the launch checkout's rule makes for a worktree carries no mark. +// Each checkout is linted by its own installed copy of the rule. The driver +// marks the message of the copy that a branch's lockfile edit installs, and +// only that copy, so a report carries the mark exactly when the session +// checkout's branch changed the plugin. The launch checkout's copy is unmarked +// in a worktree session, so a report that the launch checkout's rule makes for +// a worktree carries no mark. A settings edit installs nothing. /** Whether the session checkout's reports carry the edited rule's mark. */ function predictRuleMarked(program) { - return program.ruleEdited + return isPluginEdit(program.ruleEdited) } // -- 3. Driver ---------------------------------------------------------------- @@ -489,6 +611,75 @@ const SCRATCH = `.worktrees/scratch/${APP}/tests/scratch-oracle.test.ts` const RULE_MESSAGE = `registers tests and declares no oracle part.` const RULE_MARK = `[branch rule]` +// Packages that nothing depends on, which make a large lockfile larger than +// the 1 MiB default output buffer of a child process (about 1.2 MiB). +const PADDING = Array.from( + { length: 10_000 }, + (_, index) => + ` pad-${index}@1.0.0:\n resolution: {integrity: sha512-${`p`.repeat(88)}}\n`, +).join(`\n`) + +/** + * A pnpm v9 lockfile that pins the plugin and one unrelated package. The + * plugin has entries in the importers, packages, and snapshots sections, and + * its snapshot lists one resolved dependency. A plugin edit changes one of + * `plugin`'s parts; an unrelated edit changes `other`, which nothing else + * depends on. A large lockfile also holds the padding packages. + */ +function lockfileText({ plugin = {}, other = `1.0.0`, size = `small` } = {}) { + const { + version = `1.0.0`, + integrity = `sha512-plugin`, + dependency = `8.0.0`, + } = plugin + const name = `@tanstack/eslint-plugin-oracles` + return [ + `lockfileVersion: '9.0'`, + ``, + `importers:`, + ``, + ` .:`, + ` devDependencies:`, + ` '${name}':`, + ` specifier: ${version}`, + ` version: ${version}`, + ` left-pad:`, + ` specifier: ${other}`, + ` version: ${other}`, + ``, + `packages:`, + ``, + ` '${name}@${version}':`, + ` resolution: {integrity: ${integrity}}`, + ``, + ` left-pad@${other}:`, + ` resolution: {integrity: sha512-left-pad-${other}}`, + ``, + ...(size === `large` ? [PADDING] : []), + `snapshots:`, + ``, + ` '${name}@${version}':`, + ` dependencies:`, + ` '@typescript-eslint/utils': ${dependency}`, + ``, + ` left-pad@${other}: {}`, + ``, + ].join(`\n`) +} + +/** The edited lockfile for a configuration edit. */ +function editedLockfile(program) { + const { size } = program.lockfile + if (program.ruleEdited === LOCKFILE_EDIT) + return lockfileText({ other: `1.0.1`, size }) + const plugin = { + 'plugin-version': { version: `1.0.1` }, + 'plugin-integrity': { integrity: `sha512-plugin-rebuilt` }, + 'plugin-dependency': { dependency: `8.0.1` }, + }[program.ruleEdited] + return lockfileText({ plugin, size }) +} + /** The text of one module, with or without its edit. */ function moduleText(program, module, edited) { const lines = [] @@ -512,7 +703,8 @@ function moduleText(program, module, edited) { const UNDECLARED_TEST = `it('reads one value', () => { expect(1).toBe(1) })` let work -let pluginDir +let pluginPackage +let settingsFile let feedbackScript let runCountScript let gitignoreFile @@ -522,7 +714,8 @@ beforeAll(() => { // other's repositories. The real path keeps the file names that the driver // passes in the spelling that the script's repository root uses. work = realpathSync(mkdtempSync(join(tmpdir(), `lint-scope-oracle-`))) - pluginDir = join(repoRoot, `scripts`, `eslint-plugin-oracle`) + pluginPackage = realpathSync(join(repoRoot, PLUGIN)) + settingsFile = join(repoRoot, SETTINGS) // A mutant is run from a copy: LINT_SCOPE_SCRIPT names the copy of the // script under test, and LINT_SCOPE_GITIGNORE the copy of `.gitignore`. feedbackScript = @@ -551,9 +744,38 @@ function gitIn(cwd, ...args) { ) } +/** + * Installs the repository's dependencies in a launch checkout: a link to each + * package, and a copy of the oracle lint plugin, which the checkout owns. + */ +function installDependencies(checkout) { + const modules = join(repoRoot, `node_modules`) + mkdirSync(join(checkout, `node_modules`, `@tanstack`), { recursive: true }) + for (const entry of readdirSync(modules)) { + if (entry !== `@tanstack`) + symlinkSync(join(modules, entry), join(checkout, `node_modules`, entry)) + } + for (const entry of readdirSync(join(modules, `@tanstack`))) { + if (entry !== `eslint-plugin-oracles`) + symlinkSync( + join(modules, `@tanstack`, entry), + join(checkout, `node_modules`, `@tanstack`, entry), + ) + } + installPlugin(checkout) +} + +/** Copies the installed plugin's sources into a checkout's node_modules. */ +function installPlugin(checkout) { + cpSync(pluginPackage, join(checkout, PLUGIN), { + recursive: true, + filter: (source) => !source.includes(`${pluginPackage}/node_modules`), + }) +} + /** The path of a module in a checkout. */ function modulePath(checkout, name) { - if (name === RULE) return join(checkout, RULE) + if (CONFIGURATION.includes(name)) return join(checkout, name) if (name === PRODUCTION) return join(checkout, APP, `src`, `prod.ts`) return join(checkout, APP, `tests`, name) } @@ -578,12 +800,14 @@ function repositoryOf(program) { join(launch, APP, `package.json`), JSON.stringify({ name: `app` }), ) - // The plugin's sources, without its tests: its own oracle file would be an - // oracle file of this repository too. - cpSync(pluginDir, join(launch, `scripts`, `eslint-plugin-oracle`), { - recursive: true, - filter: (source) => !source.endsWith(`.test.js`), - }) + // The lint configuration: the settings module and a lockfile. + mkdirSync(join(launch, `scripts`), { recursive: true }) + writeFileSync(join(launch, SETTINGS), readFileSync(settingsFile, `utf8`)) + if (program.lockfile.base === `present`) + writeFileSync( + join(launch, LOCKFILE), + lockfileText({ size: program.lockfile.size }), + ) writeFileSync( join(launch, `scripts`, `agent-lint-feedback.mjs`), readFileSync(feedbackScript, `utf8`), @@ -602,12 +826,13 @@ function repositoryOf(program) { gitIn(launch, `add`, `-A`) gitIn(launch, `commit`, `-q`, `-m`, `base`) // A worktree nested in the launch checkout finds these packages by walking - // up from its own files. - symlinkSync(join(repoRoot, `node_modules`), join(launch, `node_modules`)) + // up from its own files. Each checkout has its own installed plugin. + installDependencies(launch) let checkout = launch if (program.session === `worktree`) { gitIn(launch, `worktree`, `add`, `-q`, `-b`, `feature`, WORKTREE) checkout = join(launch, WORKTREE) + installPlugin(checkout) } if (program.scratch) { mkdirSync(dirname(join(launch, SCRATCH)), { recursive: true }) @@ -620,14 +845,31 @@ function repositoryOf(program) { moduleText(program, module, true), ) } - if (program.ruleEdited) { - const rule = modulePath(checkout, RULE) + if (program.ruleEdited === SETTINGS_EDIT) { + // A settings edit adds a line to the module. + const edited = modulePath(checkout, SETTINGS) + writeFileSync(edited, `${readFileSync(edited, `utf8`)}// edited\n`) + } else if (program.ruleEdited) { + writeFileSync(modulePath(checkout, LOCKFILE), editedLockfile(program)) + } + if ( + program.lockfile.base === `missing` && + !existsSync(join(checkout, LOCKFILE)) + ) + // The branch adds the lockfile that the base commit lacks. + writeFileSync( + join(checkout, LOCKFILE), + lockfileText({ size: program.lockfile.size }), + ) + if (isPluginEdit(program.ruleEdited)) { + // The new lockfile installs a changed plugin in the session checkout. + const rule = join(checkout, RULE_SOURCE) const text = readFileSync(rule, `utf8`) if (!text.includes(RULE_MESSAGE)) throw new Error(`the rule no longer has the message ${RULE_MESSAGE}`) writeFileSync( rule, - `${text.replace(RULE_MESSAGE, `${RULE_MESSAGE} ${RULE_MARK}`)}\n// edited\n`, + text.replace(RULE_MESSAGE, `${RULE_MESSAGE} ${RULE_MARK}`), ) } return { @@ -758,15 +1000,28 @@ function compareRuleSource(marks, marked) { // oracle-edit: an oracle file that is itself edited. // unrelated-edit: an edited test module that no oracle file reaches. // production-boundary: an edited production module that an oracle file reaches. -// rule-edit: the rule's sources changed, so every oracle file is selected. +// plugin-version-edit, plugin-integrity-edit, plugin-dependency-edit: the +// lockfile changed one part of the plugin's entries, with a new installed +// plugin, so every oracle file is selected. +// lockfile-edit: the lockfile changed only another package's entries, so it +// selects nothing. +// settings-edit: the settings module changed, so every oracle file is +// selected. +// hook-lockfile-same-plugin: the edit hook names the lockfile, and the +// plugin's entries are the merge-base's, so the hook selects nothing. +// large-lockfile-same-plugin: a lockfile larger than 1 MiB is edited or +// named while the plugin's entries stay the merge-base's, so the base's +// lockfile must be read whole to find that nothing changed. +// missing-base-lockfile: the base commit has no lockfile and the branch +// adds one, so the plugin's entries are new. // untracked-dependent: an untracked oracle file reaches the hook's file. // js-oracle: a JavaScript oracle file is selected. // hook-oracle, hook-support, hook-driver, hook-production, hook-rule: the -// kind of file that the edit hook names. +// kind of file that the edit hook names; `rule` is a configuration file. // worktree-session: the session works in a worktree, and the hooks run the // launch checkout's script. -// worktree-own-rule: the worktree's branch edited the rule, so its reports -// must come from its own copy. +// worktree-own-rule: the worktree's branch changed the plugin, so its reports +// must come from its own installed copy. // scratch-directory: a launch session's checkout holds a scratch directory // under `.worktrees/`. @@ -778,7 +1033,14 @@ const STATES = [ `oracle-edit`, `unrelated-edit`, `production-boundary`, - `rule-edit`, + `plugin-version-edit`, + `plugin-integrity-edit`, + `plugin-dependency-edit`, + `lockfile-edit`, + `settings-edit`, + `hook-lockfile-same-plugin`, + `large-lockfile-same-plugin`, + `missing-base-lockfile`, `untracked-dependent`, `js-oracle`, `hook-oracle`, @@ -826,7 +1088,18 @@ function recordStates(program) { const states = new Set() const { changed, hook } = editedByScope(program) const edited = new Set(changed) - if (program.ruleEdited) states.add(`rule-edit`) + if (program.ruleEdited) states.add(`${program.ruleEdited}-edit`) + const basePresent = program.lockfile.base === `present` + const samePlugin = basePresent && !isPluginEdit(program.ruleEdited) + if (program.hookTarget === LOCKFILE && samePlugin) + states.add(`hook-lockfile-same-plugin`) + if ( + program.lockfile.size === `large` && + samePlugin && + (program.ruleEdited === LOCKFILE_EDIT || program.hookTarget === LOCKFILE) + ) + states.add(`large-lockfile-same-plugin`) + if (!basePresent) states.add(`missing-base-lockfile`) const reached = new Set() for (const oracle of program.oracles) { if (oracle.edited) states.add(`oracle-edit`) @@ -891,7 +1164,7 @@ function recordStates(program) { states.add(`hook-${target?.kind ?? `rule`}`) if (program.session === `worktree`) { states.add(`worktree-session`) - if (program.ruleEdited) states.add(`worktree-own-rule`) + if (isPluginEdit(program.ruleEdited)) states.add(`worktree-own-rule`) } else if (program.scratch) { states.add(`scratch-directory`) } @@ -917,18 +1190,38 @@ function campaign(name) { compareRuleSource(observed[scope].marks, marked) } } - it(`${name}: fixed-seed campaign`, async () => { - await fc.assert(fc.asyncProperty(programArbitrary, predicate), { - numRuns: oracleRuns(BASE_RUNS), - seed: FIXED_SEED, - }) - }, 1_200_000) - it(`${name}: random-seed campaign`, async () => { - await fc.assert( - fc.asyncProperty(programArbitrary, predicate), - oraclePropertyOptions(BASE_RUNS, PROPERTY), - ) - }, 1_200_000) + // The constructed histories run first, on top of the random ones, as the + // framework adds examples; a path replay runs only its one history. + oracleReplayTest(it)( + `${name}: fixed-seed campaign`, + async () => { + const parameters = oracleFixedSeed(FIXED_SEED, { + numRuns: oracleRuns(BASE_RUNS), + }) + const options = withOracleFixedHistories( + parameters, + constructedPrograms(FIXED_SEED), + ) + oracleRoute(oracleRouteOf(options)) + await fc.assert(fc.asyncProperty(programArbitrary, predicate), options) + }, + 1_200_000, + ) + oracleReplayTest(it, PROPERTY)( + `${name}: random-seed campaign`, + async () => { + // The constructed histories are drawn from the campaign's seed, so the + // options come first. + const parameters = oraclePropertyOptions(BASE_RUNS, PROPERTY) + const options = withOracleFixedHistories( + parameters, + constructedPrograms(parameters.seed), + ) + oracleRoute(oracleRouteOf(options)) + await fc.assert(fc.asyncProperty(programArbitrary, predicate), options) + }, + 1_200_000, + ) } describe(`lint selection after an edit`, () => { @@ -945,5 +1238,5 @@ describe(`lint selection after an edit`, () => { declareOracle({ model: [predictSelected, predictRuleMarked], check: [compareSelected, compareRuleSource], - grammar: [programArbitrary], + grammar: [programArbitrary, constructedPrograms], }) diff --git a/scripts/oracle-lint-settings.mjs b/scripts/oracle-lint-settings.mjs new file mode 100644 index 0000000000..85d6d63183 --- /dev/null +++ b/scripts/oracle-lint-settings.mjs @@ -0,0 +1,23 @@ +/** + * The project settings that @tanstack/eslint-plugin-oracles reads from + * `settings.oracles`. eslint.config.js and scripts/agent-lint-feedback.mjs + * both pass them, so `pnpm lint`, the edit hooks, and the pre-commit hook + * judge oracle files under the same settings. + * + * - `envPrefix`: the prefix of the oracle environment variables. It must match + * the `configureOracles({ envPrefix })` call in packages/db/tests/oracle-config.ts. + * - `implementationModules`: the modules that implement replay selection, so + * they may read the replay variables and select a replay from configuration. + * - `replayWitnessModules`: the module that writes a replay witness, whose + * record the standard JSON serializer writes. + */ +export const oracleLintSettings = { + oracles: { + envPrefix: `TANSTACK_DB_ORACLE`, + implementationModules: [ + `packages/db/tests/oracle-config.ts`, + `packages/db/tests/oracle-property.ts`, + ], + replayWitnessModules: [`packages/db/tests/oracle-replay-witness.ts`], + }, +} diff --git a/scripts/release-comments-oracle.property.test.js b/scripts/release-comments-oracle.property.test.js index f2e7a6c42d..fd01406518 100644 --- a/scripts/release-comments-oracle.property.test.js +++ b/scripts/release-comments-oracle.property.test.js @@ -26,6 +26,7 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, test } from 'vitest' +import { oracleRoute } from '../packages/db/tests/oracle-config.js' import { declareOracle } from '../packages/db/tests/oracle-declaration.js' // The grammar authors structured releases before serializing Markdown. Removing @@ -773,6 +774,9 @@ if (args[0] === 'api' && args[1] === 'graphql') { if (failures.length === 1) throw failures[0] } +// Owns nothing: comment-on-release.ts is a repository script outside any +// package, and the driver runs it in a child process, where the gap report +// cannot place mutants. declareOracle({ model: [expectedNotifications], check: [checkNotifications], @@ -788,8 +792,10 @@ if ( ) for (const [index, input] of histories.entries()) { if (replay !== undefined && Number(replay) !== index) continue - test(`release-comments case ${index}: ${input.version}, reverse ${input.reverse}, issues ${Object.keys(input.edges).length}, packages ${input.packages.length}`, () => - exercise(input)) + test(`release-comments case ${index}: ${input.version}, reverse ${input.reverse}, issues ${Object.keys(input.edges).length}, packages ${input.packages.length}`, () => { + oracleRoute(`case`) + return exercise(input) + }) } afterAll(() => { process.stdout.write( diff --git a/scripts/release-history-oracle.property.test.js b/scripts/release-history-oracle.property.test.js index e9dc99e3e4..ad05b021b4 100644 --- a/scripts/release-history-oracle.property.test.js +++ b/scripts/release-history-oracle.property.test.js @@ -125,7 +125,7 @@ async function recordFiniteComparison(property, action) { return withFiniteComparison(action, ({ failed }) => { if (selected && replay.replayProperty === property) recordOracleFiniteReplay(property, { - seed: replay.replaySeed, + seed: replay.campaignSeed, path: replay.replayPath, failed, }) diff --git a/scripts/run-budget-oracle.property.test.js b/scripts/run-budget-oracle.property.test.js index 2363642755..1d50b97b42 100644 --- a/scripts/run-budget-oracle.property.test.js +++ b/scripts/run-budget-oracle.property.test.js @@ -1,7 +1,12 @@ /** * Law (L5): every run budget that an oracle property uses is tracked by * scripts/check-oracle-run-counts.mjs, wherever it is written. A budget is the - * numRuns of a fixed-seed campaign, or the run count of a registered property. + * numRuns of a fixed-seed campaign, or the run count of a registered property, + * whether the property passes it to `oraclePropertyOptions` or declares it as + * `oracleProperty({ id, histories: { runs } })`; a fixed seed in that + * definition's `fixedSeeds` runs `runs` histories. A fixed seed is written as + * an object, `{ seed, numRuns }`, or declared, `oracleFixedSeed(seed, { + * numRuns })`, and an edit may change one form into the other. * When the branch lowers a budget, or removes it from every tracked oracle * file, the script reports it, so the change is visible to the reviewer. * @@ -26,7 +31,10 @@ * key is unique to its budget, except that a fixed seed can be shared with an * unrelated campaign in the second oracle file. Then the removal law treats * the seed as still present, and the model follows it. Star re-exports are not - * generated. A count may be wrapped in oracleRuns. The model judges the + * generated. A count may be wrapped in oracleRuns, except in `oracleProperty`, + * whose framework scales `runs` itself. An `oracleProperty` call is generated + * with inline `histories`; a `histories` object held in a constant, and an id + * built by a template with expressions, are not. The model judges the * reported budget, its base value, and its head value or removal; it does not * predict the wording of a line. * @@ -41,8 +49,8 @@ */ import { execFile } from 'node:child_process' import { - mkdtempSync, mkdirSync, + mkdtempSync, readFileSync, rmSync, symlinkSync, @@ -56,7 +64,10 @@ import fc from 'fast-check' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { declareOracle } from '../packages/db/tests/oracle-declaration.js' import { + oracleFixedSeed, oraclePropertyOptions, + oracleRoute, + oracleRouteOf, oracleRuns, } from '../packages/db/tests/oracle-config.js' import { createOracleReach } from '../packages/db/tests/oracle-reach.js' @@ -79,7 +90,12 @@ const LARGE_PADDING = 1_100_000 // -- 1. Grammar --------------------------------------------------------------- // -// A budget's count is written inline, in support module A, or in A and passed +// A property budget is written through `oraclePropertyOptions` or as an +// `oracleProperty` definition, and a property budget may migrate from the +// first form at base to the second at head, as a port onto the framework does. +// A fixed-seed campaign writes its seed in an object or declares it with +// `oracleFixedSeed`, at base and at head independently, as the move onto +// declared seeds does. A budget's count is written inline, in support module A, or in A and passed // on by support module B's re-export. Its key is inline or imported from A. A // change edits the count where it is defined, or writes a literal at the call. // The campaign itself is in the oracle file or in A at base (`callAt`). At @@ -88,7 +104,8 @@ const LARGE_PADDING = 1_100_000 // A are `.ts` or `.js`; an import names A with or without the `.js` suffix. const budgetSkeleton = fc.record({ - form: fc.constantFrom(`property`, `seed`), + form: fc.constantFrom(`property`, `framework`, `seed`), + migrates: fc.boolean(), source: fc.constantFrom(`inline`, `imported`, `reexported`), keySource: fc.constantFrom(`inline`, `imported`), wrapped: fc.boolean(), @@ -117,6 +134,9 @@ const budgetSkeleton = fc.record({ { weight: 2, arbitrary: fc.constant(`other-oracle`) }, ), sharedSeed: fc.boolean(), + // Whether a fixed seed is declared with oracleFixedSeed at base and at head. + declaredAtBase: fc.boolean(), + declaredAtHead: fc.boolean(), }) const fileForms = fc.record({ @@ -198,9 +218,15 @@ function resolveBudget(skeleton, index, sources, files) { return { index, form: skeleton.form, + // A property budget written through `oraclePropertyOptions`, or a + // fixed-seed campaign, at base that is an `oracleProperty` definition at + // head: the property's `runs`, or a seed in its `fixedSeeds`. + migrates: skeleton.form !== `framework` && skeleton.migrates, source, keySource: skeleton.keySource, - wrapped: skeleton.wrapped, + wrapped: skeleton.form !== `framework` && skeleton.wrapped, + declaredAtBase: skeleton.form === `seed` && skeleton.declaredAtBase, + declaredAtHead: skeleton.form === `seed` && skeleton.declaredAtHead, before: skeleton.base, after: headValue, changed: headValue !== skeleton.base, @@ -218,7 +244,7 @@ function resolveBudget(skeleton, index, sources, files) { skeleton.fate !== `other-oracle` && !files.otherNew, key: - skeleton.form === `property` + skeleton.form !== `seed` ? `property budget.${skeleton.keySource === `imported` ? `q` : `p`}${index}` : `fixed seed ${1000 + index}`, } @@ -328,9 +354,16 @@ function renderFiles(program, revision) { const count = isHead ? budget.after : budget.before const seed = 1000 + i // The definition of an imported key and count lives in support module A. + // The form a budget is written in at this revision. + const form = + isHead && budget.migrates + ? budget.form === `seed` + ? `framework-seed` + : `framework` + : budget.form if (budget.keySource === `imported`) { support.line( - budget.form === `property` + budget.form !== `seed` ? `export const PROP_${i} = 'budget.q${i}'` : `export const SEED_${i} = ${seed}`, ) @@ -369,20 +402,27 @@ function renderFiles(program, revision) { : budget.source === `reexported` && !inSupport ? use(`RUNS_${i}`, files.reexport) : use(`RUNS_${i}`, files.support) - const runs = budget.wrapped ? `oracleRuns(${countText})` : countText + // A migrated budget drops its oracleRuns wrapper, as the framework scales. + const runs = + budget.wrapped && !form.startsWith(`framework`) + ? `oracleRuns(${countText})` + : countText const keyText = budget.keySource === `imported` - ? use( - budget.form === `property` ? `PROP_${i}` : `SEED_${i}`, - files.support, - ) - : budget.form === `property` + ? use(budget.form !== `seed` ? `PROP_${i}` : `SEED_${i}`, files.support) + : budget.form !== `seed` ? `'budget.p${i}'` : `${seed}` const call = - budget.form === `property` + form === `property` ? `export const opts${i} = oraclePropertyOptions(${runs}, ${keyText})` - : `export function campaign${i}() { fc.assert(fc.property(fc.integer(), () => undefined), { numRuns: ${runs}, seed: ${keyText} }) }` + : form === `framework` + ? `export const opts${i} = oracleProperty({ id: ${keyText}, law: {}, histories: { arbitrary: fc.integer(), runs: ${runs} } })` + : form === `framework-seed` + ? `export const opts${i} = oracleProperty({ id: 'seeded.${i}', law: {}, histories: { arbitrary: fc.integer(), runs: ${runs}, fixedSeeds: [${keyText}] } })` + : (isHead ? budget.declaredAtHead : budget.declaredAtBase) + ? `export function campaign${i}() { fc.assert(fc.property(fc.integer(), () => undefined), oracleFixedSeed(${keyText}, { numRuns: ${runs} })) }` + : `export function campaign${i}() { fc.assert(fc.property(fc.integer(), () => undefined), { numRuns: ${runs}, seed: ${keyText} }) }` hostText.line(call) // The oracle file uses a campaign in A by importing it. if (inSupport) { @@ -390,7 +430,9 @@ function renderFiles(program, revision) { .get(files.oracle) .import( supportSpecifier, - budget.form === `property` ? `opts${i}` : `campaign${i}`, + budget.form !== `seed` || (isHead && budget.migrates) + ? `opts${i}` + : `campaign${i}`, ) } } @@ -534,6 +576,9 @@ const STATES = [ `imported-count`, `re-exported-count`, `imported-key`, + `framework-definition`, + `migrated-to-framework`, + `seed-migrated-to-framework`, `support-only-change`, `oracle-and-support-change`, `lowered-budget`, @@ -544,6 +589,8 @@ const STATES = [ `moved-to-oracle`, `moved-to-untracked-oracle`, `shared-seed-kept`, + `declared-seed`, + `seed-declared-at-head`, `large-base-file`, `js-oracle`, `js-support`, @@ -559,7 +606,20 @@ function recordStates(program) { if (budget.source === `imported`) states.add(`imported-count`) if (budget.source === `reexported`) states.add(`re-exported-count`) if (budget.keySource === `imported`) states.add(`imported-key`) + if (budget.form === `framework`) states.add(`framework-definition`) + if (budget.migrates) + states.add( + budget.form === `seed` + ? `seed-migrated-to-framework` + : `migrated-to-framework`, + ) if (budget.after < budget.before) states.add(`lowered-budget`) + if (budget.form === `seed` && !budget.migrates) { + if (budget.declaredAtBase && budget.declaredAtHead) + states.add(`declared-seed`) + if (!budget.declaredAtBase && budget.declaredAtHead) + states.add(`seed-declared-at-head`) + } if (budget.callAt === `support`) states.add(`campaign-in-support`) if (budget.callAt === `oracle` && budget.fate === `swap`) { states.add(`moved-into-support`) @@ -592,9 +652,10 @@ function recordStates(program) { for (const state of states) reach.reach(state) } -// Each campaign has its own fixed seed, so the four campaigns do not draw the -// same programs. The fixed options are written where the campaign is named, so -// the run-count script tracks each budget. +// Each campaign has its own fixed seed, declared with oracleFixedSeed, so the +// four campaigns do not draw the same programs and each fixed campaign runs on +// the `fixed seed` route. The fixed options are written where the campaign is +// named, so the run-count script tracks each budget. function campaign(name, sources, fixed, supportOnly = false) { const arbitrary = programArbitrary(sources, supportOnly) const predicate = async (program) => { @@ -602,30 +663,36 @@ function campaign(name, sources, fixed, supportOnly = false) { compareReport(await reportOf(program), predictReport(program)) } it(`${name}: fixed-seed campaign`, async () => { + oracleRoute(oracleRouteOf(fixed)) await fc.assert(fc.asyncProperty(arbitrary, predicate), fixed) }, 900_000) it(`${name}: random-seed campaign`, async () => { - await fc.assert( - fc.asyncProperty(arbitrary, predicate), - oraclePropertyOptions(BASE_RUNS, PROPERTY), - ) + const parameters = oraclePropertyOptions(BASE_RUNS, PROPERTY) + oracleRoute(oracleRouteOf(parameters)) + await fc.assert(fc.asyncProperty(arbitrary, predicate), parameters) }, 900_000) } describe(`run budgets that the run-count script tracks`, () => { - campaign(`inline and imported counts`, [`inline`, `imported`], { - seed: FIXED_SEED, - numRuns: oracleRuns(BASE_RUNS), - }) - campaign(`re-exported counts`, [`reexported`], { - seed: 2235, - numRuns: oracleRuns(BASE_RUNS), - }) - campaign(`every source`, ALL, { seed: 2236, numRuns: oracleRuns(BASE_RUNS) }) + campaign( + `inline and imported counts`, + [`inline`, `imported`], + oracleFixedSeed(FIXED_SEED, { numRuns: oracleRuns(BASE_RUNS) }), + ) + campaign( + `re-exported counts`, + [`reexported`], + oracleFixedSeed(2235, { numRuns: oracleRuns(BASE_RUNS) }), + ) + campaign( + `every source`, + ALL, + oracleFixedSeed(2236, { numRuns: oracleRuns(BASE_RUNS) }), + ) campaign( `support-only edits`, [`imported`, `reexported`], - { seed: 2237, numRuns: oracleRuns(BASE_RUNS) }, + oracleFixedSeed(2237, { numRuns: oracleRuns(BASE_RUNS) }), true, ) // Every declared state must be entered by the campaigns above. diff --git a/scripts/strip-oracle-probes-oracle.property.test.js b/scripts/strip-oracle-probes-oracle.property.test.js new file mode 100644 index 0000000000..b0493de7c5 --- /dev/null +++ b/scripts/strip-oracle-probes-oracle.property.test.js @@ -0,0 +1,651 @@ +/** + * Law (scripts.strip-oracle-probes): a build that strips the oracle state + * probes gives the program that the production build of the source gives. For + * every program that the probe-form rule accepts, the stripped program (1) + * keeps no runtime reference that came from a guard, and (2) behaves like the + * source with `process.env.NODE_ENV` defined as "production", where each guard + * is `false` and no probe runs. Where the stripper cannot keep both promises, + * it fails the build with a message and emits no changed code. + * + * Authority: the maintainer decision of 2026-11-09, which removes the probes + * from published builds because unbundled Node paid for the `process.env` read + * in every guard. The probe-form rule's header in + * @tanstack/eslint-plugin-oracles is the one definition of the form: the guard + * is a `const probing` with the guard initializer, in the function's own + * scope, and the probe is one expression statement `probing && globalThis + * .__oracleProbe?.(name, condition)`. Parentheses never matter, and the guard + * may sit at the function's top, in one of its blocks, or in a switch case. + * The rule admits any other use of `probing`, and it rejects every mention of + * `__oracleProbe` outside a probe. + * + * Refusal decisions, from that rule. A surviving reference to `probing` is a + * runtime read or binding that the stripper cannot remove with its guard. The + * stripper refuses a value read, a parameter or other binding named + * `probing`, a guard in a `for` head, and a `probing` declaration whose + * initializer is not the guard form. It accepts a property name, a label, and + * a type position, which hold no runtime reference to the guard. It refuses + * every `__oracleProbe` mention that is left, because the rule rejects each + * of them: a type-only mention can be admitted only if the rule changes first. + * + * Limits. The grammar draws programs with one exported function, up to six + * units, and a fixed set of probe forms, placements, and mentions. A program + * that the rule rejects is not checked, and the grammar's admission model must + * agree with the rule on every drawn program. The reference is esbuild with + * a define, which is a separate implementation of the same constant folding; + * the observations are one trace, the probe calls, the thrown error, and the + * identifiers of the stripped output. The programs do not touch `process` + * except through the guard, so an identifier check on `process` covers the + * guard only. Programs run in `new Function`, not in a bundle, and the probe + * recorder is installed on `globalThis` for each run. + * + * Structure: + * 1. The grammar draws a probe-bearing function, with guards and probes in + * lint-legal placements, and hostile uses of `probing` and `__oracleProbe`. + * 2. The admission model predicts, from the drawn units alone, whether the + * rule accepts the program. The rule itself decides, and the two must + * agree on every program. + * 3. The model predicts the refusal and the number of `probing` identifiers + * that the stripped output keeps. + * 4. The driver stripts each accepted program with the plugin's transform, + * then strips types with esbuild. The reference is esbuild with + * `process.env.NODE_ENV` defined as "production". Both run in + * `new Function`, with a probe recorder. + * 5. The comparison checks each observation against the model, and reach + * counts the declared placements that the accepted histories enter. + */ +import { Linter } from 'eslint' +import tsParser from '@typescript-eslint/parser' +import oraclePlugin from '@tanstack/eslint-plugin-oracles' +import { transform } from 'esbuild' +import fc from 'fast-check' +import ts from 'typescript' +import { afterAll, describe, expect, it } from 'vitest' +import { declareOracle } from '../packages/db/tests/oracle-declaration.js' +import { + oracleFixedSeed, + oraclePropertyOptions, + oracleRuns, + withOracleFixedHistories, +} from '../packages/db/tests/oracle-config.js' +import { createOracleReach } from '../packages/db/tests/oracle-reach.js' +import { stripOracleProbes } from './strip-oracle-probes.mjs' + +const PROPERTY = `scripts.strip-oracle-probes` +const FIXED_SEED = 3117 +const BASE_RUNS = 160 +// The plugin's id names a TypeScript module, so it strips the program. +const MODULE_ID = `packages/db/src/generated.ts` +const GUARD_INIT = `typeof process === 'undefined' ? false : process.env.NODE_ENV !== 'production'` +const NAMES = [`pkg#alpha`, `db-ivm#gamma.delta`] + +// -- 1. Grammar --------------------------------------------------------------- +// +// A program is one exported function with a top-level guard variant and up to +// six units. Each unit is a marker, a probe in one of five forms, a probe in a +// lint-legal placement, a probe in a hostile or a rule-rejected position, or a +// mention of `probing` that is not a reference. The units are the grammar's +// inputs only: nothing here says what the stripped program should do. + +const TOP_WEIGHTS = [ + [4, `plain`], + [2, `parenthesized`], + [2, `siblings-before`], + [2, `siblings-after`], + [1, `none`], + [1, `non-guard`], +] + +const PROBE_FORMS = [ + `plain`, + `paren-statement`, + `paren-guard`, + `cast-receiver`, + `paren-callee`, +] + +// Units that take a probe, and where their statement sits. +const PROBE_UNITS = new Set([ + `probe`, + `if-body`, + `if-else-body`, + `for-body`, + `while-body`, + `do-body`, + `labeled-body`, + `block-guard`, + `switch-guard`, + `arrow-guard`, + `for-head`, +]) +// Placements whose probe sits at the function's top level, under its guard. +const TOP_LEVEL_PROBE_UNITS = new Set([ + `probe`, + `if-body`, + `if-else-body`, + `for-body`, + `while-body`, + `do-body`, + `labeled-body`, +]) +const GUARD_TOPS = new Set([ + `plain`, + `parenthesized`, + `siblings-before`, + `siblings-after`, +]) +// Units the rule rejects, each for its own reason. +const REJECTED_UNITS = new Set([ + `bare-call`, + `wrong-operand`, + `one-argument`, + `bad-name`, + `assignment-condition`, + `outer-guard-arrow`, + `type-only-mention`, +]) +// Units that read or bind `probing` at runtime, which the stripper refuses. +const REFUSED_UNITS = new Set([`value-read`, `binding`]) +// `probing` names that are not references, and the `probing` identifiers that +// the stripped output keeps for each unit, as the grammar wrote them. +const MENTION_TOKENS = { property: 2, label: 2 } + +const UNIT_WEIGHTS = [ + [6, `marker`], + [5, `probe`], + [2, `if-body`], + [2, `if-else-body`], + [2, `for-body`], + [1, `while-body`], + [1, `do-body`], + [1, `labeled-body`], + [2, `block-guard`], + [2, `switch-guard`], + [2, `arrow-guard`], + [1, `for-head`], + [1, `value-read`], + [1, `binding`], + [2, `type-only`], + [2, `property`], + [2, `label`], + [1, `bare-call`], + [1, `wrong-operand`], + [1, `one-argument`], + [1, `bad-name`], + [1, `assignment-condition`], + [1, `outer-guard-arrow`], + [1, `type-only-mention`], +] + +const weighted = (entries) => + fc.oneof( + ...entries.map(([weight, value]) => ({ + weight, + arbitrary: fc.constant(value), + })), + ) + +const unitArbitrary = fc.record({ + kind: weighted(UNIT_WEIGHTS), + form: fc.constantFrom(...PROBE_FORMS), + name: fc.constantFrom(...NAMES), +}) + +const programArbitrary = fc.record({ + top: weighted(TOP_WEIGHTS), + hostProcess: fc.boolean(), + units: fc.array(unitArbitrary, { minLength: 1, maxLength: 6 }), + fixed: fc.constant(false), +}) + +// The probe's text, without its statement terminator. Each form is a legal +// spelling of the probe, or a cast receiver, and the condition runs a push so +// a probe that the stripper leaves would also change the trace. +function probeText(form, name, index) { + const condition = `trace.push('condition-${index}') > 0` + const call = `globalThis.__oracleProbe?.('${name}', ${condition})` + switch (form) { + case `plain`: + return `probing && ${call}` + case `paren-statement`: + return `(probing && ${call})` + case `paren-guard`: + return `(probing) && ${call}` + case `cast-receiver`: + return `probing && (globalThis as { __oracleProbe?: (name: string, condition: unknown) => void }).__oracleProbe?.('${name}', ${condition})` + case `paren-callee`: + return `probing && (globalThis.__oracleProbe)?.('${name}', ${condition})` + default: + throw new Error(`unknown probe form ${form}`) + } +} + +function topText(top) { + switch (top) { + case `plain`: + return `const probing = ${GUARD_INIT};` + case `parenthesized`: + return `const probing = (${GUARD_INIT});` + case `siblings-before`: + return `const sibling = trace.push('sibling'), probing = ${GUARD_INIT};` + case `siblings-after`: + return `const probing = ${GUARD_INIT}, sibling = trace.push('sibling');` + case `none`: + return `` + case `non-guard`: + return `const probing = trace.push('non-guard') > 0;` + default: + throw new Error(`unknown guard variant ${top}`) + } +} + +function unitText(unit, index) { + const probe = probeText(unit.form, unit.name, index) + switch (unit.kind) { + case `marker`: + return `trace.push('marker-${index}');` + case `probe`: + return `${probe};` + case `if-body`: + return `if (trace.length < 0) ${probe};` + case `if-else-body`: + return `if (trace.length >= 0) trace.push('then-${index}'); else ${probe};` + case `for-body`: + return `for (let k${index} = 0; k${index} < 2; k${index}++) ${probe};` + case `while-body`: + return `while (trace.length < 0) ${probe};` + case `do-body`: + return `do ${probe}; while (trace.length < 0);` + case `labeled-body`: + return `label${index}: ${probe};` + case `block-guard`: + return `{ const probing = ${GUARD_INIT}; ${probe}; trace.push('block-${index}'); }` + case `switch-guard`: + return `switch (trace.length) { case 0: const probing = ${GUARD_INIT}; ${probe}; break; default: trace.push('default-${index}'); }` + case `arrow-guard`: + return `const arrow${index} = (): void => { const probing = ${GUARD_INIT}; ${probe}; trace.push('arrow-${index}'); }; arrow${index}();` + case `for-head`: + return `for (const probing = ${GUARD_INIT}; trace.length < 0; ) { ${probe}; }` + case `value-read`: + return `trace.push(String(probing));` + case `binding`: + return `((probing: boolean): void => { trace.push(String(probing)); })(true);` + case `type-only`: + return `type Type${index} = typeof probing;` + case `property`: + return `const object${index} = { probing: 1 }; trace.push(String(object${index}.probing));` + case `label`: + return `probing: { trace.push('label-${index}'); break probing; }` + case `bare-call`: + return `globalThis.__oracleProbe?.('${unit.name}', true);` + case `wrong-operand`: + return `trace.length && globalThis.__oracleProbe?.('${unit.name}', true);` + case `one-argument`: + return `probing && globalThis.__oracleProbe?.('${unit.name}');` + case `bad-name`: + return `probing && globalThis.__oracleProbe?.('NoHash', true);` + case `assignment-condition`: + return `probing && globalThis.__oracleProbe?.('${unit.name}', (trace.length = 0) === 0);` + case `outer-guard-arrow`: + return `const outer${index} = (): void => { ${probe}; };` + case `type-only-mention`: + return `type Mention${index} = { __oracleProbe?: number };` + default: + throw new Error(`unknown unit ${unit.kind}`) + } +} + +function programText(spec) { + const units = spec.units.map((unit, index) => unitText(unit, index)) + return [ + `export function run(trace: string[]): void {`, + topText(spec.top), + ...units, + `}`, + ].join(`\n`) +} + +// The constructed histories: fixed regressions for the placements and forms +// that the stripper handled wrongly, written as unit lists. Each one is an +// input to the same grammar, model, and comparison as the drawn programs. +const u = (kind, form = `plain`, name = NAMES[0]) => ({ kind, form, name }) +const constructedPrograms = () => [ + { + top: `plain`, + hostProcess: false, + fixed: true, + units: [u(`if-body`), u(`marker`)], + }, + { + top: `plain`, + hostProcess: true, + fixed: true, + units: [u(`if-else-body`), u(`marker`)], + }, + { + top: `plain`, + hostProcess: false, + fixed: true, + units: [u(`for-body`), u(`while-body`), u(`do-body`), u(`marker`)], + }, + { + top: `plain`, + hostProcess: true, + fixed: true, + units: [u(`labeled-body`), u(`marker`)], + }, + { + top: `plain`, + hostProcess: false, + fixed: true, + units: [u(`probe`, `paren-statement`), u(`marker`)], + }, + { + top: `plain`, + hostProcess: true, + fixed: true, + units: [u(`probe`, `paren-guard`), u(`marker`)], + }, + { + top: `parenthesized`, + hostProcess: false, + fixed: true, + units: [u(`probe`), u(`marker`)], + }, + { + top: `siblings-after`, + hostProcess: true, + fixed: true, + units: [u(`probe`), u(`marker`)], + }, + { + top: `siblings-before`, + hostProcess: false, + fixed: true, + units: [u(`probe`), u(`marker`)], + }, + { + top: `none`, + hostProcess: false, + fixed: true, + units: [u(`block-guard`), u(`switch-guard`), u(`arrow-guard`)], + }, + { top: `none`, hostProcess: true, fixed: true, units: [u(`for-head`)] }, + { top: `plain`, hostProcess: false, fixed: true, units: [u(`value-read`)] }, + { top: `none`, hostProcess: true, fixed: true, units: [u(`binding`)] }, + { top: `non-guard`, hostProcess: false, fixed: true, units: [u(`marker`)] }, + { top: `plain`, hostProcess: true, fixed: true, units: [u(`marker`)] }, + { + top: `plain`, + hostProcess: false, + fixed: true, + units: [u(`property`), u(`label`), u(`type-only`), u(`marker`)], + }, + { + top: `plain`, + hostProcess: true, + fixed: true, + units: [u(`probe`, `cast-receiver`), u(`probe`, `paren-callee`)], + }, +] + +// The placements and forms that each accepted program enters. A reach state +// is named for the placement, the form, or the guard variant it exercises. +const STATES = [ + ...TOP_WEIGHTS.map(([, top]) => `guard-top-${top}`), + ...UNIT_WEIGHTS.filter(([, kind]) => !REJECTED_UNITS.has(kind)).map( + ([, kind]) => `unit-${kind}`, + ), + ...PROBE_FORMS.map((form) => `form-${form}`), +] + +function reachedStates(spec) { + const states = [`guard-top-${spec.top}`] + for (const unit of spec.units) { + states.push(`unit-${unit.kind}`) + if (PROBE_UNITS.has(unit.kind)) states.push(`form-${unit.form}`) + } + return states +} + +// -- 2. Model ----------------------------------------------------------------- +// +// The model predicts from the drawn units. It does not run the stripper, and +// it does not read the rule's code. Its admission prediction says whether the +// probe-form rule would accept the program: a top-level probe needs a guard at +// the function's top, a rule-rejected unit is never admitted, and every other +// placement has its own guard in its own scope. + +function predictAdmission(spec) { + if (spec.units.some((unit) => REJECTED_UNITS.has(unit.kind))) return false + const hasTopLevelProbe = spec.units.some((unit) => + TOP_LEVEL_PROBE_UNITS.has(unit.kind), + ) + return !hasTopLevelProbe || GUARD_TOPS.has(spec.top) +} + +// A non-guard `probing` declaration and a value read, binding, or `for` head +// guard all leave a `probing` reference that the stripper cannot remove. +function predictRefusal(spec) { + return ( + spec.top === `non-guard` || + spec.units.some( + (unit) => REFUSED_UNITS.has(unit.kind) || unit.kind === `for-head`, + ) + ) +} + +// The `probing` identifiers that the stripped output keeps, when it does not +// refuse: the mentions that are not references, and nothing from the guard. +function predictProbingTokens(spec) { + return spec.units.reduce( + (count, unit) => count + (MENTION_TOKENS[unit.kind] ?? 0), + 0, + ) +} + +function predictOutcome(spec) { + return { + refuse: predictRefusal(spec), + probingTokens: predictProbingTokens(spec), + } +} + +// -- 3. Driver ---------------------------------------------------------------- + +// The probe-form rule, run as ESLint runs it. A program that does not parse is +// a grammar error, not a rejection. +const linter = new Linter({ configType: `flat` }) +const lintConfig = [ + { + files: [`**/*.ts`], + languageOptions: { + parser: tsParser, + ecmaVersion: `latest`, + sourceType: `module`, + }, + plugins: { oracle: oraclePlugin }, + rules: { 'oracle/probe-form': `error` }, + }, +] + +function ruleAccepts(text) { + const messages = linter.verify(text, lintConfig, { filename: `generated.ts` }) + const fatal = messages.find((message) => message.fatal) + if (fatal) { + throw new Error( + `the grammar drew a program that does not parse: ${fatal.message}`, + ) + } + return messages.length === 0 +} + +// The identifiers of an emitted JavaScript module that name the guard, the +// probe, or the host's `process`. Types are gone by then. +function identifierCounts(code) { + const file = ts.createSourceFile( + `generated.js`, + code, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ) + const counts = { probing: 0, __oracleProbe: 0, process: 0 } + const visit = (node) => { + if (ts.isIdentifier(node) && Object.hasOwn(counts, node.text)) { + counts[node.text]++ + } + ts.forEachChild(node, visit) + } + visit(file) + return counts +} + +// Runs an emitted module, with a probe recorder on `globalThis` that must +// never be called. `process` is present with a development environment, or +// absent, as a bundle with no host process would leave it. +function runModule(code, hostProcess) { + const program = new Function(`process`, `${code}\nreturn __program;`)( + hostProcess ? { env: { NODE_ENV: `development` } } : undefined, + ) + const trace = [] + const probes = [] + const previous = Object.getOwnPropertyDescriptor(globalThis, `__oracleProbe`) + globalThis.__oracleProbe = (name) => { + probes.push(name) + } + try { + try { + program.run(trace) + return { trace, probes, error: null } + } catch (error) { + return { trace, probes, error: `${error.name}: ${error.message}` } + } + } finally { + if (previous) Object.defineProperty(globalThis, `__oracleProbe`, previous) + else delete globalThis.__oracleProbe + } +} + +// Reference: the source with `process.env.NODE_ENV` defined as "production". +async function referenceObservation(text, hostProcess) { + const { code } = await transform(text, { + loader: `ts`, + format: `iife`, + globalName: `__program`, + define: { 'process.env.NODE_ENV': `"production"` }, + }) + return runModule(code, hostProcess) +} + +// Production path: the plugin's transform, then esbuild's type stripping +// without a define. A refusal is the transform's thrown error. +async function strippedObservation(text, hostProcess) { + let source = text + try { + const stripped = stripOracleProbes().transform(text, MODULE_ID) + if (stripped !== null) source = stripped.code + } catch (error) { + return { refusal: error.message } + } + const { code } = await transform(source, { + loader: `ts`, + format: `iife`, + globalName: `__program`, + }) + return { + ...runModule(code, hostProcess), + identifiers: identifierCounts(code), + } +} + +// -- 4. Comparison ------------------------------------------------------------ + +function compareOutcome(expected, reference, observed) { + // The reference never calls the probe, whatever the program. + expect(reference.probes).toEqual([]) + if (expected.refuse) { + expect( + observed.refusal, + `the stripper must refuse this program`, + ).toBeTypeOf(`string`) + expect(observed.refusal).toMatch(/probing|__oracleProbe/) + return + } + expect(observed.refusal, `the stripper refused a program it must keep`).toBe( + undefined, + ) + expect(observed.probes).toEqual([]) + expect(observed.trace).toEqual(reference.trace) + expect(observed.error).toEqual(reference.error) + expect(observed.identifiers).toEqual({ + probing: expected.probingTokens, + __oracleProbe: 0, + process: 0, + }) +} + +const tally = { accepted: 0, rejected: 0, refused: 0 } + +async function checkProgram(spec) { + const text = programText(spec) + const admitted = ruleAccepts(text) + expect( + admitted, + `the admission model and the probe-form rule disagree on:\n${text}`, + ).toBe(predictAdmission(spec)) + if (!admitted) { + expect(spec.fixed, `a constructed history must be admitted:\n${text}`).toBe( + false, + ) + tally.rejected++ + return + } + reach.beginHistory({ fixed: spec.fixed === true }) + for (const state of reachedStates(spec)) reach.reach(state) + tally.accepted++ + const expected = predictOutcome(spec) + if (expected.refuse) tally.refused++ + const reference = await referenceObservation(text, spec.hostProcess) + const observed = await strippedObservation(text, spec.hostProcess) + compareOutcome(expected, reference, observed) +} + +// fast-check passes each example as an argument tuple. +const constructedExamples = () => constructedPrograms().map((spec) => [spec]) + +const reach = createOracleReach(PROPERTY, STATES, { + minimum: 2, + fixedHistories: constructedPrograms().length, +}) + +declareOracle({ + model: [predictAdmission, predictOutcome], + check: [compareOutcome], + grammar: [programArbitrary, constructedPrograms], +}) + +describe(`production stripping of the probe form`, () => { + it(`fixed-seed campaign`, async () => { + const parameters = oracleFixedSeed(FIXED_SEED, { + numRuns: oracleRuns(BASE_RUNS), + }) + await fc.assert( + fc.asyncProperty(programArbitrary, checkProgram), + withOracleFixedHistories(parameters, constructedExamples()), + ) + }, 600_000) + it(`random-seed campaign`, async () => { + const parameters = oraclePropertyOptions(BASE_RUNS, PROPERTY) + await fc.assert( + fc.asyncProperty(programArbitrary, checkProgram), + withOracleFixedHistories(parameters, constructedExamples()), + ) + }, 600_000) + afterAll(() => { + if (process.env.TANSTACK_DB_ORACLE_REACH_REPORT) { + console.log(`ADMISSION`, JSON.stringify(tally)) + console.log(`REACH`, JSON.stringify([...reach.counts()])) + } + reach.assertReached() + }) +}) diff --git a/scripts/strip-oracle-probes.d.mts b/scripts/strip-oracle-probes.d.mts new file mode 100644 index 0000000000..a42020f827 --- /dev/null +++ b/scripts/strip-oracle-probes.d.mts @@ -0,0 +1,3 @@ +import type { Plugin } from 'vite' + +export declare function stripOracleProbes(): Plugin diff --git a/scripts/strip-oracle-probes.mjs b/scripts/strip-oracle-probes.mjs new file mode 100644 index 0000000000..15a2587a94 --- /dev/null +++ b/scripts/strip-oracle-probes.mjs @@ -0,0 +1,268 @@ +// Removes the development-only oracle state probes from a package's +// published build (maintainer decision, 2026-11-09). The probe-form lint rule +// gives every probe one shape: +// +// const probing = typeof process === 'undefined' ? false : process.env.NODE_ENV !== 'production' +// probing && globalThis.__oracleProbe?.(name, condition) +// +// A bundler that defines `process.env.NODE_ENV` already drops them, but a +// server that loads the published files without a bundler still reads +// `process.env` once per probing call, which made a small `consolidate()` two +// to five times slower. This plugin blanks each guard declaration and probe +// statement, so the published files carry neither. It keeps every line +// break, so source maps stay correct. +// +// It matches the rule's form, so it strips what the rule admits and nothing +// else. Parentheses never matter, as in the rule. A guard declaration is +// blanked when its initializer is the guard form; a declaration with sibling +// declarators keeps them. A statement that is blanked becomes `;`, so it still +// stands as the body of an unbraced `if`, loop, or label. A `probing` that the +// stripper cannot remove with its guard, such as a value read, a binding, a +// `for` head guard, or a non-guard declaration, and any `__oracleProbe` that +// is left, fails the build instead of shipping a probe or a `ReferenceError`. +// A property name, a label, or a type position named `probing` is not a +// reference, and it stays. The stripper refuses every `__oracleProbe` left +// because the rule rejects each mention outside a probe. +// +// It runs only in `vite build`. Vitest, and the gap report through Vitest, +// load the sources with their probes. The law and its oracle are in +// scripts/strip-oracle-probes-oracle.property.test.js. +import ts from 'typescript' + +const GUARD = `probing` +const PROBE = `__oracleProbe` + +const blank = (text) => text.replace(/[^\n]/g, ` `) +// One empty statement in the blanked text's place, with its line breaks. +const emptyStatement = (text) => `;` + blank(text.slice(1)) + +// The expression with its parentheses removed. The rule reads the expression +// that the parentheses enclose, so a parenthesized form is the same form. +function unparen(node) { + let inner = node + while (ts.isParenthesizedExpression(inner)) inner = inner.expression + return inner +} + +// The text of a plain string literal, or of a template literal without +// substitutions, which the repository's sources write with backticks. +function staticText(node) { + const inner = unparen(node) + if (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner)) + return inner.text + return undefined +} + +function isIdentifier(node, text) { + const inner = unparen(node) + return ts.isIdentifier(inner) && inner.text === text +} + +// `process.env.NODE_ENV`, the rule's member chain, with no optional link. +function isProcessEnvNodeEnv(node) { + const nodeEnv = unparen(node) + if ( + !ts.isPropertyAccessExpression(nodeEnv) || + nodeEnv.questionDotToken || + nodeEnv.name.text !== `NODE_ENV` + ) + return false + const env = unparen(nodeEnv.expression) + return ( + ts.isPropertyAccessExpression(env) && + !env.questionDotToken && + env.name.text === `env` && + isIdentifier(env.expression, `process`) + ) +} + +// `process.env.NODE_ENV !== 'production'`, the guard's second operand. +function isProductionGuard(node) { + const comparison = unparen(node) + return ( + ts.isBinaryExpression(comparison) && + comparison.operatorToken.kind === + ts.SyntaxKind.ExclamationEqualsEqualsToken && + isProcessEnvNodeEnv(comparison.left) && + staticText(comparison.right) === `production` + ) +} + +// `typeof process === 'undefined' ? false : process.env.NODE_ENV !== 'production'` +function isGuardInit(node) { + if (node === undefined) return false + const conditional = unparen(node) + if (!ts.isConditionalExpression(conditional)) return false + const test = unparen(conditional.condition) + return ( + ts.isBinaryExpression(test) && + test.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken && + ts.isTypeOfExpression(unparen(test.left)) && + isIdentifier(unparen(test.left).expression, `process`) && + staticText(test.right) === `undefined` && + unparen(conditional.whenTrue).kind === ts.SyntaxKind.FalseKeyword && + isProductionGuard(conditional.whenFalse) + ) +} + +// A `const probing` declarator with the guard's initializer. +function isGuardDeclarator(declaration) { + return ( + ts.isIdentifier(declaration.name) && + declaration.name.text === GUARD && + isGuardInit(declaration.initializer) + ) +} + +// One expression statement `probing && `. +function isProbeStatement(expression) { + const probe = unparen(expression) + return ( + ts.isBinaryExpression(probe) && + probe.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + isIdentifier(probe.left, GUARD) && + mentions(probe.right, PROBE) + ) +} + +function mentions(node, name) { + let found = false + const visit = (child) => { + if (found) return + if (ts.isIdentifier(child) && child.text === name) found = true + else ts.forEachChild(child, visit) + } + visit(node) + return found +} + +// Whether an identifier named `probing` is a runtime reference or a binding. +// The names that are not one: a property name, a label, a JSX attribute name, +// a binding element's property key, and a type-level position. +function isRuntimeReference(identifier) { + const parent = identifier.parent + if ( + (ts.isPropertyAccessExpression(parent) || + ts.isPropertyAssignment(parent) || + ts.isPropertySignature(parent) || + ts.isPropertyDeclaration(parent) || + ts.isMethodDeclaration(parent) || + ts.isMethodSignature(parent) || + ts.isGetAccessorDeclaration(parent) || + ts.isSetAccessorDeclaration(parent) || + ts.isEnumMember(parent) || + ts.isJsxAttribute(parent)) && + parent.name === identifier + ) + return false + if ( + (ts.isLabeledStatement(parent) || + ts.isBreakStatement(parent) || + ts.isContinueStatement(parent)) && + parent.label === identifier + ) + return false + if (ts.isBindingElement(parent) && parent.propertyName === identifier) + return false + if ( + (ts.isTypeAliasDeclaration(parent) || + ts.isInterfaceDeclaration(parent) || + ts.isTypeParameterDeclaration(parent)) && + parent.name === identifier + ) + return false + for (let ancestor = parent; ancestor; ancestor = ancestor.parent) { + if (ts.isTypeNode(ancestor) && !ts.isExpressionWithTypeArguments(ancestor)) + return false + } + return true +} + +// The source ranges to blank, each with how to blank it. A statement becomes +// an empty statement; a sibling-declarator guard loses only its own text and +// the comma beside it. +function stripRanges(file) { + const ranges = [] + const visit = (node) => { + if ( + ts.isVariableStatement(node) && + node.declarationList.flags & ts.NodeFlags.Const + ) { + const declarations = node.declarationList.declarations + const index = declarations.findIndex(isGuardDeclarator) + if (index === -1) return ts.forEachChild(node, visit) + if (declarations.length === 1) { + ranges.push([node.getStart(file), node.end, emptyStatement]) + return + } + // The comma goes with the guard: the one after it, or the one before it + // when the guard is last. + const [start, end] = + index === 0 + ? [declarations[0].getStart(file), declarations[1].getStart(file)] + : [declarations[index - 1].end, declarations[index].end] + ranges.push([start, end, blank]) + return ts.forEachChild(node, visit) + } + if (ts.isExpressionStatement(node) && isProbeStatement(node.expression)) { + ranges.push([node.getStart(file), node.end, emptyStatement]) + return + } + ts.forEachChild(node, visit) + } + visit(file) + return ranges +} + +// The first identifier that names the probe, or a guard reference that the +// stripper cannot remove, if any survives. +function survivingReference(file) { + let found + const visit = (node) => { + if (found) return + if ( + ts.isIdentifier(node) && + (node.text === PROBE || (node.text === GUARD && isRuntimeReference(node))) + ) + found = node + else ts.forEachChild(node, visit) + } + visit(file) + return found +} + +export function stripOracleProbes() { + return { + name: `strip-oracle-probes`, + apply: `build`, + enforce: `pre`, + transform(code, id) { + if ( + !/\.[cm]?[jt]sx?$/.test(id) || + !(code.includes(PROBE) || code.includes(GUARD)) + ) + return null + const parse = (text) => + ts.createSourceFile(id, text, ts.ScriptTarget.Latest, true) + let stripped = code + for (const [start, end, blankText] of stripRanges(parse(code)).sort( + ([a], [b]) => b - a, + )) + stripped = + stripped.slice(0, start) + + blankText(stripped.slice(start, end)) + + stripped.slice(end) + const file = parse(stripped) + const survivor = survivingReference(file) + if (survivor) { + const { line } = file.getLineAndCharacterOfPosition( + survivor.getStart(file), + ) + throw new Error( + `${id}:${line + 1}: \`${survivor.text}\` is a reference that stripping cannot remove (it is outside a probe statement, or it reads or binds the guard); the published build must not carry it`, + ) + } + return { code: stripped, map: null } + }, + } +}